A Swift package wrapping the Live2D Cubism SDK for Native, rendering through
Metal. Load a .model3.json, drive its parameters (lipsync, blink, gaze, anything
the rig exposes), play its authored motions, and draw it into an MTKView, from
Swift, on iOS and macOS.
Live2DRuntime.start()
let model = try Live2DModel(contentsOf: hiyoriURL) // .model3.json
model.startMotion(group: "Idle", index: 0) // authored clip
model.setValue(0.6, for: "ParamMouthOpenY") // your lipsyncClone it, stage the SDK yourself, and reference it by path. This package can
never be a normal .package(url:…, from:…) dependency, for two structural reasons:
- It carries none of Live2D's software. Cubism Core is under the Live2D
Proprietary Software License and the Framework under the Live2D Open Software
License; neither is mine to redistribute. Everything Live2D-owned lives under
the gitignored
Vendor/, staged locally from your own SDK download. - A C++ target needs
unsafeFlags, and SwiftPM forbidsunsafeFlagsin version-pinned dependencies. Path (or branch) dependencies are the only shape SwiftPM allows.
So the flow is:
git clone <this repo>
# download CubismSdkForNative from https://www.live2d.com/en/sdk/download/native/
cd Live2DKit
Scripts/bootstrap.sh # defaults to ~/Downloads/CubismSdkForNative-5-r.5
Scripts/build-metallibs.sh # compile the Metal shader libraries
swift build && swift test # verify…then, in your app's Package.swift or Xcode project, add Live2DKit as a local
path dependency pointing at your clone.
bootstrap.sh assembles Vendor/Live2DCubismCore.xcframework from the Release
slices (iOS device, universal simulator, universal macOS) and copies the Framework
sources. It records the SDK version in Vendor/CUBISM_VERSION so a stale checkout
is diagnosable. build-metallibs.sh compiles Cubism's shaders into
Vendor/FrameworkMetallibs-<sdk>/, one directory per SDK (iphoneos,
iphonesimulator, macosx).
Two things ship in the app's bundle, not in this package:
-
The shader libraries. Copy
Vendor/FrameworkMetallibs-<sdk>/(the slice matching the SDK you're building for) into your app bundle as a directory namedFrameworkMetallibs/. A build phase keyed on$PLATFORM_NAMEdoes this cleanly:rsync -a --delete \ "$LIVE2DKIT/Vendor/FrameworkMetallibs-$PLATFORM_NAME/" \ "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/FrameworkMetallibs/"
Why: Cubism loads its shaders by file, at runtime, from the hosting app's main bundle. See the shader story below. Without this directory no model will ever render (
Live2DRendererwill refuse withisReady == falserather than let Cubism abort the process). -
A model. Cubism's sample models (Hiyori et al.) are in the SDK download under their own Free Material License. Bundle one for testing, or ship your own. A model directory holds
Name.model3.jsonplus the.moc3, textures, and optionalphysics3.json/pose3.json/motions/.
| type | role |
|---|---|
Live2DRuntime |
process-wide Cubism lifecycle (start() before anything else) |
Live2DModel |
one loaded model: parameters, groups, physics, pose, motions |
Live2DRenderer |
draws a model into a Metal render pass |
A frame goes: evaluateMotions(deltaTime:) → your parameter writes →
update(deltaTime:) → draw(…). That order is a contract: motion evaluation
discards writes made before it (Cubism's LoadParameters sandwich), and update
runs physics/pose and pushes everything into vertex state.
import Live2DKit
import MetalKit
final class CharacterViewController: UIViewController, MTKViewDelegate {
private var model: Live2DModel!
private var renderer: Live2DRenderer!
private var queue: MTLCommandQueue!
private var lastFrame: CFTimeInterval?
override func viewDidLoad() {
super.viewDidLoad()
let device = MTLCreateSystemDefaultDevice()!
let mtkView = MTKView(frame: view.bounds, device: device)
mtkView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
Live2DRenderer.configure(mtkView) // Cubism hard-codes its formats; this sets them
mtkView.delegate = self
view.addSubview(mtkView)
Live2DRuntime.start()
let url = Bundle.main.url(forResource: "Hiyori", withExtension: "model3.json",
subdirectory: "Hiyori")!
model = try! Live2DModel(contentsOf: url)
let size = mtkView.drawableSize
renderer = Live2DRenderer(model: model, device: device,
width: Int(size.width), height: Int(size.height))!
precondition(renderer.isReady, "FrameworkMetallibs/ missing from the app bundle")
// Textures come from the model's own manifest, bound in order, loaded
// AS STORED (no flip; Cubism's UVs are authored against the atlas as-is)
// with straight alpha (the loader does not premultiply; nor should you).
let loader = MTKTextureLoader(device: device)
let manifest = try! JSONSerialization.jsonObject(
with: Data(contentsOf: url)) as! [String: Any]
let refs = manifest["FileReferences"] as! [String: Any]
for (index, name) in (refs["Textures"] as! [String]).enumerated() {
let textureURL = url.deletingLastPathComponent().appendingPathComponent(name)
renderer.bindTexture(try! loader.newTexture(URL: textureURL, options: [:]), at: index)
}
queue = device.makeCommandQueue()
model.startMotion(group: "Idle", index: 0)
}
func draw(in view: MTKView) {
let now = CACurrentMediaTime()
let dt = lastFrame.map { now - $0 } ?? 0
lastFrame = now
if model.motionsFinished { // rotate idle clips
model.startMotion(group: "Idle", index: Int.random(in: 0..<model.motionCount(inGroup: "Idle")))
}
model.evaluateMotions(deltaTime: dt) // 1. authored motion
model.setValue(mouthOpenness, for: "ParamMouthOpenY") // 2. your writes
model.update(deltaTime: dt) // 3. physics + pose + commit
guard let descriptor = view.currentRenderPassDescriptor,
let drawable = view.currentDrawable,
let commandBuffer = queue.makeCommandBuffer() else { return }
renderer.draw(commandBuffer: commandBuffer,
renderPassDescriptor: descriptor,
drawableSize: view.drawableSize)
commandBuffer.present(drawable)
commandBuffer.commit()
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
renderer.resize(width: Int(size.width), height: Int(size.height))
}
var mouthOpenness: Float { 0 } // feed your lipsync here
}Wrap the same machinery in a representable. Build everything once in
makeUIView; per-frame state is pushed to the object, never rebuilt from body:
import Live2DKit
import MetalKit
import SwiftUI
struct Live2DView: UIViewRepresentable {
let modelURL: URL
var mouthOpen: Float = 0
func makeCoordinator() -> Renderer { Renderer(modelURL: modelURL) }
func makeUIView(context: Context) -> MTKView {
let view = MTKView(frame: .zero, device: MTLCreateSystemDefaultDevice())
Live2DRenderer.configure(view)
view.clearColor = MTLClearColorMake(0, 0, 0, 0) // transparent stage
view.isOpaque = false
view.delegate = context.coordinator
return view
}
func updateUIView(_ uiView: MTKView, context: Context) {
context.coordinator.mouthOpen = mouthOpen // push, don't rebuild
}
final class Renderer: NSObject, MTKViewDelegate {
var mouthOpen: Float = 0
// model / renderer / queue built lazily on first draw, exactly as in the
// UIKit example; omitted here for brevity.
init(modelURL: URL) { /* … */ }
func draw(in view: MTKView) { /* the same 1-2-3 frame as above */ }
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { /* resize */ }
}
}A complete, runnable integration (model load, texture binding, motion playback,
offscreen render) lives in Tools/snapshot/main.swift, which is also the
package's headless verification tool:
Scripts/snapshot.sh /tmp/frame.png Vendor/Models/Hiyori/Hiyori.model3.json 2 Idle:0Demo/ is a complete SwiftUI app: Hiyori full-bleed on the system background,
rotating her authored Idle clips (never the same twice in a row) with an
asymmetric procedural blink multiplied into the clip's eyelid curves and gaze
saccades composed over its eye curves. Tap her and she plays her TapBody clip.
Reduce Motion parks the clips and damps the gaze; blink stays.
# after bootstrap.sh + build-metallibs.sh:
cd Demo && xcodegen generate && open Live2DKitDemo.xcodeprojIt consumes the package by path (..), exactly as the section above prescribes,
so it doubles as a working reference for the build phase and the frame loop.
Requires XcodeGen; the generated
project is not committed.
- Groups, not guesses: models disagree on parameter names (Hiyori lipsyncs via
ParamMouthOpenY, Mao viaParamA). Readmodel.lipSyncParameterIDs/eyeBlinkParameterIDsinstead of hard-coding. Unknown IDs are safely ignored. - Framing:
renderer.framingScale/framingOffsetYzoom and shift the fitted model in screen space. This is how a full-figure canvas becomes an upper-body portrait without touching the model. - Pose fade:
model.setPoseFadeDuration(_:)overridespose3.json's part-swap cross-dissolve. Models with swappable part groups (alternate arm sets, say) ghost both variants for the authored fade time when motions switch between them. - Motions are one-shot even when authored
Loop: true, somotionsFinishedactually fires and you own the rotation policy. Starting a new clip mid-play cross-fades.
The most surprising integration detail, and it is not optional. Cubism does not use
a compiled-in default Metal library. At init, CubismShader_Metal::GenerateShaders
looks its shaders up by file, at runtime, in the hosting app's main bundle:
[[NSBundle mainBundle] URLForResource:@"MetalShaders"
withExtension:@"metallib"
subdirectory:@"FrameworkMetallibs"]SwiftPM cannot produce that shape: it compiles a target's .metal sources into one
default.metallib inside the package's resource bundle, where Cubism never looks.
So the .metal files are excluded from the SwiftPM target and built by
Scripts/build-metallibs.sh into Vendor/FrameworkMetallibs-<sdk>/, which your app
embeds (see above).
Two further traps in the same area:
- Six
*Blend.metalfiles only compile withCSM_COLOR_BLEND_MODE/CSM_ALPHA_BLEND_MODEdefined; Cubism builds each once per blend-mode pair, 474 variants in total.FragShaderSrcColorBlend.metalandFragShaderSrcAlphaBlend.metalare#includefragments and must never be compiled standalone. Compiling any of these bare is what a naive SwiftPM build does, and it fails loudly. - When the library is missing, the process aborts.
GenerateShadersnull-checks the library and returns, which looks like a graceful degrade, but that check is unreachable: with no resource the URL is nil and-newLibraryWithURL:asserts (url must not be nil.) first.Live2DRenderertherefore checks for the library itself and refuses to build, soisReadyreports false instead of the app dying. CheckisReady: false means the model will never appear.
| target | kind | why |
|---|---|---|
Live2DCubismCore |
binary | the proprietary Core, staged not committed |
CubismFramework |
C++ | Live2D's sources + the Metal renderer |
CubismBridge |
ObjC++ | a narrow ObjC surface over the C++ API |
Live2DKit |
Swift | what callers use |
Cubism's public API is heavyweight C++ (its own csmVector/csmString
containers, CubismUserModel, CubismRenderer_Metal), which Swift's C++ interop
does not consume cleanly. Hence the ObjC++ shim. Keep it narrow: every symbol
crossing that boundary is one Swift can't see through, and a wide bridge becomes a
second SDK to maintain.
- Cubism is process-global and not thread-safe. The framework, allocator, and id manager are shared mutable state; this package serializes load and teardown, and you should drive a given model from one thread (the main thread is fine).
- ARC is off for
CubismFrameworkonly. Cubism's Metal renderer sendsautoreleasedirectly, which ARC forbids.CubismBridgeis this package's code and keeps ARC. - The allocator is load-bearing. Cubism reads model data through aligned vector
loads, so
AllocateAlignedmust genuinely align; a plainmallocfaults on some payloads rather than merely being slower. - Delta times are clamped to 0.1 s so a resume-from-background hitch can't slingshot physics pendulums or fast-forward a motion.
The wrapper code in this repository is MIT-licensed (see LICENSE). The Live2D
Cubism SDK is Live2D Inc.'s, under their own terms: the Live2D Proprietary
Software License (Core), the Live2D Open Software License (Framework), and the
Free Material License (sample models). Business users above Live2D's revenue
threshold need a Cubism SDK Release License. Nothing in this repository grants any
right to Live2D's software, and none of it is included here.
