@airisu-dev/react-native-live2d-skia 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/Live2dSkia.podspec +159 -0
- package/README.md +258 -2
- package/android/CMakeLists.txt +113 -0
- package/android/build.gradle +223 -0
- package/android/fix-prefab.gradle +51 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +9 -0
- package/android/src/main/java/com/live2dskia/Live2dSkiaPackage.kt +20 -0
- package/app.plugin.js +2 -0
- package/cli/build/index.js +157 -0
- package/cpp/cubism/CubismCompat.hpp +26 -0
- package/cpp/cubism/CubismHelpers.hpp +27 -0
- package/cpp/cubism/CubismRendererStub.cpp +14 -0
- package/cpp/cubism/CubismSetup.cpp +54 -0
- package/cpp/cubism/CubismSetup.hpp +9 -0
- package/cpp/cubism/HeldParameterUpdater.hpp +25 -0
- package/cpp/cubism/Live2DUserModel.cpp +125 -0
- package/cpp/cubism/Live2DUserModel.hpp +57 -0
- package/cpp/cubism/Live2DUserModelAnimation.cpp +107 -0
- package/cpp/drawing/JsiUnwrap.hpp +25 -0
- package/cpp/drawing/MeshCache.cpp +75 -0
- package/cpp/drawing/MeshCache.hpp +45 -0
- package/cpp/drawing/SkiaDrawer.cpp +138 -0
- package/cpp/drawing/SkiaDrawer.hpp +42 -0
- package/cpp/hybrid/HybridLive2DModel.cpp +174 -0
- package/cpp/hybrid/HybridLive2DModel.hpp +64 -0
- package/cpp/hybrid/HybridLive2dSkia.cpp +10 -0
- package/cpp/hybrid/HybridLive2dSkia.hpp +11 -0
- package/ios/Bridge.h +8 -0
- package/nitro.json +20 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/Live2dSkia+autolinking.cmake +82 -0
- package/nitrogen/generated/android/Live2dSkia+autolinking.gradle +27 -0
- package/nitrogen/generated/android/Live2dSkiaOnLoad.cpp +49 -0
- package/nitrogen/generated/android/Live2dSkiaOnLoad.hpp +34 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/live2dskia/Live2dSkiaOnLoad.kt +35 -0
- package/nitrogen/generated/ios/Live2dSkia+autolinking.rb +62 -0
- package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Bridge.cpp +17 -0
- package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Bridge.hpp +27 -0
- package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Umbrella.hpp +38 -0
- package/nitrogen/generated/ios/Live2dSkiaAutolinking.mm +35 -0
- package/nitrogen/generated/ios/Live2dSkiaAutolinking.swift +16 -0
- package/nitrogen/generated/shared/c++/DrawableData.hpp +116 -0
- package/nitrogen/generated/shared/c++/HybridLive2DModelSpec.cpp +39 -0
- package/nitrogen/generated/shared/c++/HybridLive2DModelSpec.hpp +86 -0
- package/nitrogen/generated/shared/c++/HybridLive2dSkiaSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridLive2dSkiaSpec.hpp +66 -0
- package/nitrogen/generated/shared/c++/MotionPriority.hpp +80 -0
- package/package.json +149 -4
- package/plugin/build/index.d.ts +14 -0
- package/plugin/build/index.js +81 -0
- package/src/components/Live2D.tsx +131 -0
- package/src/components/index.ts +2 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/useLive2DGesture.ts +140 -0
- package/src/hooks/useLive2DModel.ts +47 -0
- package/src/index.ts +5 -0
- package/src/loader/cubismJson.ts +23 -0
- package/src/loader/index.ts +9 -0
- package/src/loader/loadModel.ts +195 -0
- package/src/loader/modelContext.ts +80 -0
- package/src/loader/modelUrl.ts +112 -0
- package/src/native.ts +4 -0
- package/src/rendering/decodeImage.ts +12 -0
- package/src/rendering/drawer.ts +256 -0
- package/src/rendering/skia.ts +26 -0
- package/src/rendering/skiaTypes.ts +99 -0
- package/src/rendering/ticker/addToTicker.ts +52 -0
- package/src/rendering/ticker/constants.ts +11 -0
- package/src/rendering/ticker/frames.ts +35 -0
- package/src/rendering/ticker/index.ts +5 -0
- package/src/rendering/ticker/loop.ts +115 -0
- package/src/rendering/ticker/state.ts +65 -0
- package/src/rendering/ticker/stats.ts +36 -0
- package/src/rendering/ticker/types.ts +57 -0
- package/src/specs/react-native-live2d-skia.nitro.ts +88 -0
- package/src/utils/clock.ts +10 -0
|
@@ -0,0 +1,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,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
|
+
}
|