@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,80 @@
1
+ import type { CubismJson, Live2DModelSource } from './loadModel'
2
+
3
+ /** A Metro `require.context` of a model's folder; the path must be a string literal for Metro to bundle it. */
4
+ export interface Live2DModelContext {
5
+ keys(): string[]
6
+ (id: string): unknown
7
+ }
8
+
9
+ export interface Model3Json {
10
+ FileReferences?: {
11
+ Moc?: string
12
+ Textures?: string[]
13
+ Physics?: string
14
+ Pose?: string
15
+ Motions?: Record<string, { File: string }[]>
16
+ Expressions?: { Name: string; File: string }[]
17
+ }
18
+ }
19
+
20
+ export function isModelContext(
21
+ source: Live2DModelSource | Live2DModelContext,
22
+ ): source is Live2DModelContext {
23
+ return typeof source === 'function'
24
+ }
25
+
26
+ /** Resolves `file` relative to the folder of `from`, a context key or a URL. */
27
+ export function resolvePath(from: string, file: string): string {
28
+ const parts = from
29
+ .replace(/[?#].*$/, '')
30
+ .split('/')
31
+ .slice(0, -1)
32
+ for (const part of file.split('/')) {
33
+ if (part === '..') parts.pop()
34
+ else if (part !== '.' && part !== '') parts.push(part)
35
+ }
36
+ return parts.join('/')
37
+ }
38
+
39
+ export function sourceFromContext(context: Live2DModelContext): Live2DModelSource {
40
+ const keys = context.keys()
41
+ const model3Keys = keys.filter((k) => k.endsWith('.model3.json'))
42
+ if (model3Keys.length !== 1) {
43
+ throw new Error(
44
+ `Live2D: expected one .model3.json in the require.context, found ${
45
+ model3Keys.length === 0 ? 'none' : model3Keys.join(', ')
46
+ }`,
47
+ )
48
+ }
49
+ const model3Key = model3Keys[0]!
50
+ const settings = context(model3Key) as Model3Json
51
+ const refs = settings.FileReferences ?? {}
52
+
53
+ const load = <T>(file: string): T => {
54
+ const key = resolvePath(model3Key, file)
55
+ if (!keys.includes(key)) {
56
+ throw new Error(
57
+ `Live2D: ${file} (listed in ${model3Key}) isn't in the require.context. Check its filter.`,
58
+ )
59
+ }
60
+ return context(key) as T
61
+ }
62
+
63
+ if (!refs.Moc) throw new Error(`Live2D: ${model3Key} has no FileReferences.Moc`)
64
+ return {
65
+ settings: settings as CubismJson,
66
+ moc: load(refs.Moc),
67
+ textures: (refs.Textures ?? []).map((t) => load(t)),
68
+ physics: refs.Physics ? load(refs.Physics) : undefined,
69
+ pose: refs.Pose ? load(refs.Pose) : undefined,
70
+ motions: Object.fromEntries(
71
+ Object.entries(refs.Motions ?? {}).map(([group, motions]) => [
72
+ group,
73
+ motions.map((m) => load<CubismJson>(m.File)),
74
+ ]),
75
+ ),
76
+ expressions: Object.fromEntries(
77
+ (refs.Expressions ?? []).map((e) => [e.Name, load<CubismJson>(e.File)]),
78
+ ),
79
+ }
80
+ }
@@ -0,0 +1,112 @@
1
+ import { fetch } from 'expo/fetch'
2
+ import type { CubismJson, Live2DModelSource } from './loadModel'
3
+ import { type Model3Json, resolvePath } from './modelContext'
4
+
5
+ /** How remote model files are requested. */
6
+ export interface Live2DLoadOptions {
7
+ /** Sent with every request for a remote file, e.g. `{ Authorization: 'Bearer …' }`. */
8
+ headers?: Record<string, string>
9
+ /**
10
+ * Gives the URL to request for a file the `model3.json` lists, e.g. to sign
11
+ * each one through your backend. `url` is resolved next to the `model3.json`,
12
+ * without a query; `file` is the path as listed. Replaces the default, which
13
+ * reuses the `model3.json` URL's query (e.g. a token) for files on its host.
14
+ */
15
+ resolveUrl?: (url: string, file: string) => string | Promise<string>
16
+ }
17
+
18
+ const ABSOLUTE = /^[a-z][a-z0-9+.-]*:\/\//i
19
+
20
+ export function isRemote(source: unknown): source is string {
21
+ return typeof source === 'string' && /^https?:\/\//i.test(source)
22
+ }
23
+
24
+ /** A URL without its query and hash, which can hold tokens: for error messages. */
25
+ export function redactUrl(url: string): string {
26
+ return url.replace(/[?#].*$/, '')
27
+ }
28
+
29
+ const origin = (url: string) => /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i.exec(url)?.[0]
30
+ const queryOf = (url: string) => /\?[^#]*/.exec(url)?.[0] ?? ''
31
+
32
+ async function request(url: string, options: Live2DLoadOptions) {
33
+ let res: Awaited<ReturnType<typeof fetch>>
34
+ try {
35
+ res = await fetch(url, { headers: options.headers })
36
+ } catch (e) {
37
+ const reason = e instanceof Error ? e.message : String(e)
38
+ throw new Error(`Live2D: failed to fetch ${redactUrl(url)} (${reason})`)
39
+ }
40
+ if (!res.ok) {
41
+ throw new Error(`Live2D: failed to fetch ${redactUrl(url)} (${res.status})`)
42
+ }
43
+ return res
44
+ }
45
+
46
+ export async function fetchBytes(url: string, options: Live2DLoadOptions): Promise<ArrayBuffer> {
47
+ return (await request(url, options)).arrayBuffer()
48
+ }
49
+
50
+ async function fetchJson<T>(url: string, options: Live2DLoadOptions): Promise<T> {
51
+ return (await (await request(url, options)).json()) as T
52
+ }
53
+
54
+ // Escapes a model3.json file path for a URL: names can have spaces. Already
55
+ // escaped segments are decoded first, so they aren't escaped twice.
56
+ function encodePath(file: string): string {
57
+ return file
58
+ .split('/')
59
+ .map((part) => {
60
+ if (part === '.' || part === '..') return part
61
+ try {
62
+ return encodeURIComponent(decodeURIComponent(part))
63
+ } catch {
64
+ return encodeURIComponent(part)
65
+ }
66
+ })
67
+ .join('/')
68
+ }
69
+
70
+ /** Fetches a `model3.json` and the JSON files it lists; the moc and textures stay URLs. */
71
+ export async function sourceFromUrl(
72
+ url: string,
73
+ options: Live2DLoadOptions = {},
74
+ ): Promise<Live2DModelSource> {
75
+ const settings = await fetchJson<Model3Json>(url, options)
76
+ const refs = settings.FileReferences ?? {}
77
+ if (!refs.Moc) {
78
+ throw new Error(`Live2D: ${redactUrl(url)} has no FileReferences.Moc`)
79
+ }
80
+
81
+ const query = queryOf(url)
82
+ const resolve = async (file: string): Promise<string> => {
83
+ const plain = ABSOLUTE.test(file) ? file : resolvePath(url, encodePath(file))
84
+ if (options.resolveUrl) return options.resolveUrl(plain, file)
85
+ // The model3.json's query (e.g. a signing token) goes to its own host only.
86
+ if (!query || origin(plain) !== origin(url)) return plain
87
+ return plain.includes('?') ? `${plain}&${query.slice(1)}` : plain + query
88
+ }
89
+ const json = async (file: string) => fetchJson<CubismJson>(await resolve(file), options)
90
+
91
+ const motionGroups = Object.entries(refs.Motions ?? {})
92
+ const [moc, textures, physics, pose, motions, expressions] = await Promise.all([
93
+ resolve(refs.Moc),
94
+ Promise.all((refs.Textures ?? []).map(resolve)),
95
+ refs.Physics ? json(refs.Physics) : undefined,
96
+ refs.Pose ? json(refs.Pose) : undefined,
97
+ Promise.all(motionGroups.map(([, list]) => Promise.all(list.map((m) => json(m.File))))),
98
+ Promise.all((refs.Expressions ?? []).map((e) => json(e.File))),
99
+ ])
100
+
101
+ return {
102
+ settings: settings as CubismJson,
103
+ moc,
104
+ textures,
105
+ physics,
106
+ pose,
107
+ motions: Object.fromEntries(motionGroups.map(([group], i) => [group, motions[i]!])),
108
+ expressions: Object.fromEntries(
109
+ (refs.Expressions ?? []).map((e, i) => [e.Name, expressions[i]!]),
110
+ ),
111
+ }
112
+ }
package/src/native.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { NitroModules } from 'react-native-nitro-modules'
2
+ import type { Live2dSkia as Live2dSkiaSpec } from './specs/react-native-live2d-skia.nitro'
3
+
4
+ export const Live2dSkia = NitroModules.createHybridObject<Live2dSkiaSpec>('Live2dSkia')
@@ -0,0 +1,12 @@
1
+ import type { SkData, SkImage, SkImageFactory } from './skiaTypes'
2
+
3
+ /** Null when the data can't be decoded. Throws when this Skia has neither factory method. */
4
+ export function decodeImage(factory: SkImageFactory, data: SkData): SkImage | null {
5
+ if ('MakeFromEncoded' in factory && factory.MakeFromEncoded) return factory.MakeFromEncoded(data)
6
+ if ('MakeImageFromEncoded' in factory && factory.MakeImageFromEncoded) {
7
+ return factory.MakeImageFromEncoded(data)
8
+ }
9
+ throw new Error(
10
+ 'Live2D: this version of react-native-skia has neither Image.MakeFromEncoded nor Image.MakeImageFromEncoded',
11
+ )
12
+ }
@@ -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,26 @@
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 { SkiaModule } from './skiaTypes'
5
+
6
+ export type * from './skiaTypes'
7
+
8
+ // Guarded requires: Metro (Expo enables optional dependencies) bundles the
9
+ // missing one as a require that throws. v3 first: in a monorepo both can be
10
+ // installed, and an app on v2 maps react-native-skia to it in its Metro config.
11
+ function loadSkia(): SkiaModule {
12
+ try {
13
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
14
+ return require('react-native-skia') as SkiaModule
15
+ } catch {}
16
+ try {
17
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
18
+ return require('@shopify/react-native-skia') as SkiaModule
19
+ } catch {}
20
+ throw new Error(
21
+ 'react-native-live2d-skia needs react-native-skia (v3) or @shopify/react-native-skia (v2).',
22
+ )
23
+ }
24
+
25
+ export const { Skia, BlendMode, VertexMode, TileMode, FilterMode, MipmapMode, Group, Picture } =
26
+ loadSkia()
@@ -0,0 +1,99 @@
1
+ // The part of react-native-skia that this package uses, as structural types, with no import of
2
+ // Skia itself. rendering/skia.ts loads the real thing, and scripts/skiaCompat.ts checks every
3
+ // supported Skia version against these types.
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 | null, bounds?: SkRect | null): number
46
+ restore(): void
47
+ drawVertices(vertices: SkVertices, mode: number, paint: SkPaint): void
48
+ }
49
+
50
+ export interface SkPictureRecorder {
51
+ beginRecording(bounds?: SkRect): SkCanvas
52
+ finishRecordingAsPicture(): SkPicture
53
+ }
54
+
55
+ export interface SkData {
56
+ dispose(): void
57
+ }
58
+
59
+ // Skia 3.8 renamed MakeImageFromEncoded to MakeFromEncoded; v2 and older v3 only have the old name.
60
+ export type SkImageFactory =
61
+ | { MakeFromEncoded(data: SkData): SkImage | null }
62
+ | { MakeImageFromEncoded(data: SkData): SkImage | null }
63
+
64
+ export interface SkiaApi {
65
+ XYWHRect(x: number, y: number, width: number, height: number): SkRect
66
+ Paint(): SkPaint
67
+ PictureRecorder(): SkPictureRecorder
68
+ MakeVertices(
69
+ mode: number,
70
+ positions: SkPoint[],
71
+ textures?: SkPoint[] | null,
72
+ colors?: undefined,
73
+ indices?: number[] | null,
74
+ ): SkVertices
75
+ Image: SkImageFactory
76
+ Data: { fromBytes(bytes: Uint8Array): SkData }
77
+ }
78
+
79
+ export interface SkiaModule {
80
+ Skia: SkiaApi
81
+ BlendMode: {
82
+ SrcOver: number
83
+ Dst: number
84
+ DstIn: number
85
+ DstOut: number
86
+ Plus: number
87
+ Multiply: number
88
+ }
89
+ VertexMode: { Triangles: number }
90
+ TileMode: { Clamp: number }
91
+ FilterMode: { Linear: number }
92
+ MipmapMode: { Linear: number }
93
+ Group: ComponentType<{
94
+ transform?: Record<string, number>[]
95
+ children?: ReactNode
96
+ }>
97
+ // A picture or a shared value of one (Reanimated).
98
+ Picture: ComponentType<{ picture: unknown }>
99
+ }
@@ -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
+ }