OpenBone for Swift
A Swift package for iOS and macOS that plays OpenBone documents (.obone / .obonb): skeletal animation, skins,
state machines with inputs, triggers and pointer listeners, events, sound (AVFoundation) and scripts. It is a thin layer
over the C++ runtime — SwiftPM compiles the C++ sources directly and the Swift API wraps its stable C API
(openbone.h) — so it poses, draws and reacts exactly like the other runtimes. It passes the whole
conformance suite (21 reference files, 92 cases).
| Platforms | iOS 15+, macOS 12+ (tvOS 15+ for the runtime and views; untested); the runtime part also builds on Linux |
| Swift | 5.9+ (swift-tools-version 5.9; checked with Swift 6.0.3) |
| Products | OpenBone (one library: runtime API + Metal views + SwiftUI wrapper) |
| Targets | COpenBone (C++17 runtime, C API), CQuickJS (quickjs-ng 0.10.1), OpenBone (Swift), OpenBoneTests |
| Dependencies | none at build time beyond the C++ runtime’s own: nlohmann/json (MIT, vendored in runtimes/cpp), QuickJS-ng (MIT, vendored in runtimes/cpp/third_party/quickjs) |
| Licence | MIT |
Install
Section titled “Install”The package manifest is at the repository root (Package.swift), so add the repository URL and a release version:
-
Xcode: File → Add Package Dependencies…, enter
https://github.com/UXCentury/open-bone, pick Up to Next Major Version from0.2.0, and add theOpenBonelibrary to your target. -
Package.swift:dependencies: [.package(url: "https://github.com/UXCentury/open-bone", from: "0.2.0")],targets: [.target(name: "MyApp", dependencies: [.product(name: "OpenBone", package: "open-bone")])]
Releases are the repository’s v0.2.0-style tags (SwiftPM reads a leading v as part of the tag, not the version).
SwiftPM clones the whole repository, but builds only the Swift package’s targets.
SwiftPM compiles runtimes/cpp from source through the symlinks in Sources/COpenBone (src, cpp-include,
third_party, include/openbone.h) and QuickJS (quickjs-ng 0.10.1, MIT,
vendored in runtimes/cpp/third_party/quickjs) through Sources/CQuickJS/vendor, so document scripts are always on.
The manifest uses no unsafe flags (which SwiftPM refuses in packages used by version): bit-exact results without
fused multiply-add come from #pragma STDC FP_CONTRACT OFF in runtimes/cpp/src/exact_math.h (included first by every
runtime source) and in the Sources/CQuickJS/*.c wrappers, rather than -ffp-contract=off.
To work on a local checkout instead, use a path dependency on the repository root:
.package(name: "OpenBone", path: "../open-bone"), or Add Local… in Xcode.
Build and test
Section titled “Build and test”From the repository root (SwiftPM also finds the root manifest from runtimes/swift):
swift buildswift testswift test runs every case of conformance/expected/*.json through the Swift API (ConformanceTests, one test
per reference file, with the suite’s tolerances and the C++ runner’s comparison rules), API tests (loading and errors,
.obonb = .obone, inputs and triggers, pointer → listener events, skins, queueing, clips, text, lifetimes) and
tests of the platform-neutral renderer geometry (framing, mesh building and batching) and sound logic (AudioTests:
the document’s audio listings, OpenBoneAudioMixer gain, fades and voices with a recording output).
import OpenBone
let doc = try OpenBoneDocument(contentsOf: Bundle.main.url(forResource: "button", withExtension: "obone")!)let inst = try OpenBoneInstance(document: doc) // scripts: true by default
inst.onEvent = { e in print("event", e.name, e.stringValue) }inst.onAudio = { cmd in print(cmd) } // .play / .stop / .fade (OpenBoneView plays them)inst.onStateChange = { layer, from, to in print(layer, "→", to) }
try inst.useStateMachine() // or: try inst.play("walk", loop: true)try inst.setInput("speed", 0.5) // Bool, Double, String or OpenBoneValuetry inst.fire("attack")try inst.pointer(.down, x: 10, y: 20) // skeleton space (y up); or artboardX:artboardY:
inst.advance(1.0 / 60)inst.withDrawList { list in // zero copy; views valid inside the closure for item in list.items { // item.vertices / uvs / indices (UnsafeBufferPointer), item.color, item.dark, item.colors (particles), // item.textureID, item.blend, item.clip (→ list.clip(i)), item.text, item.stroke }}let copy = inst.drawList() // or a copied OpenBoneDrawListSwiftUI, UIKit, AppKit
Section titled “SwiftUI, UIKit, AppKit”struct Stage: View { let instance: OpenBoneInstance var body: some View { OpenBonePlayer(instance: instance, fit: .contain).frame(height: 320) }}
let view = try OpenBoneView(document: doc, stateMachine: "button") // UIView / NSView (MTKView)view.fit = .contain // .cover, .fill, .scale(2)view.framing = .artboard // .bounds, .rect(…)view.speed = 1OpenBoneView advances its instance every frame (MTKView’s display loop, isPaused stops it), draws with
OpenBoneMetalRenderer and turns touches (UIKit) or mouse down / drag / up / move (AppKit) into pointer events for
the running state machine’s listeners, using the inverse of the view’s framing. The renderer:
- one premultiplied-alpha pipeline per blend mode (normal, additive, multiply, screen);
- two-colour tint in the fragment shader:
rgb = (dark·a + (light − dark)·tex.rgb)·light.a(dark = 0 is a plain multiply); - clipping masks in the stencil buffer (a fresh stencil value per mask);
- textures decoded with ImageIO from
data:URLs or paths relative to the document (OpenBoneDocument.baseURL); straight-alpha images are premultiplied on upload, premultiplied atlas pages are kept as they are;setTexture(_:forID:)lets you supply your own; - particles with the built-in soft dot sprite and per-vertex colours; shapes untextured, with their stroke;
- text rasterised with CoreText (font family, weight, size, alignment, fill, outline) into cached textures, drawn on the text box’s quad;
- batching: consecutive items with the same texture, blend and clip become one draw call
(
OpenBoneRenderMesh— platform-neutral, unit-tested).
OpenBoneView (and so OpenBonePlayer) plays the document’s sounds by default through an OpenBoneAudio
(AVFoundation, one AVAudioPlayer per voice). Each file is read once, from a data: URL or a path relative to the
document; a voice’s gain is command volume × bus volume (0 if muted) × master (0 if the document is muted), with
the command’s rate (AVAudioPlayer’s 0.5…2 range), looping and fade-in, stop fades and fade commands (the sound’s
new volume, keeping the voice’s bus and master gain). Voices stop when the instance is replaced or the view leaves
its window.
view.isAudioEnabled = false // opt out; inst.onAudio still gets every commandOpenBonePlayer(instance: inst, isAudioEnabled: false)view.audio?.volume = 0.5 // extra gain on everything the view plays
let audio = OpenBoneAudio(document: doc) // without a viewinst.onAudio = { audio.handle($0) }onAudio is independent of the view’s player: setting it does not turn the view’s sound off. OpenBoneAudio leaves
AVAudioSession alone, so set the category your app needs on iOS. The platform-neutral part — gain, voice
bookkeeping, fades — is OpenBoneAudioMixer, which drives any OpenBoneAudioOutput (use it to plug in another
engine); it is unit-tested on Linux.
OpenBoneView waits for each frame’s command buffer before the next (one shared vertex buffer); fine for a few
skeletons, a ring of buffers would be the next step for many.
Sample
Section titled “Sample”Examples/OpenBoneSample is a SwiftUI app: pick one of the repository’s examples/
(or open any .obone / .obonb), switch between its state machine and animations, set skins and speed, drive every
input (toggles, sliders, text fields, trigger buttons), click / tap the stage, and watch events, audio commands,
state changes and script logs.
cd runtimes/swift/Examples/OpenBoneSample && swift runThat runs it on macOS. For iOS, create an iOS App target in Xcode, add the repository root as a local package and the
two files in Sources/OpenBoneSample to the target (bundle the documents and their assets/ folder, or use Open…).
| Type | What it is |
|---|---|
OpenBoneDocument |
Loaded, immutable document (init(contentsOf:), init(data:baseURL:)); artboard, animations (name, duration), skins, stateMachines (inputs with type and default), textures (atlas pages, images, premultiplied flag), textureData(for:), audioAssets, audioBuses, audioMaster, audioData(for:), artboardToSkeleton |
OpenBoneInstance |
A skeleton of a document: setSkin, setTransform, play / queue / clearTracks, useStateMachine, setInput, fire, input(_:), pointer, activeStates, setText(slot:_:), advance, bones / bone(named:), slots / slot(named:), bounds, withDrawList, drawList(); callbacks onEvent, onAudio, onStateChange, onLog |
OpenBoneSkeletonBounds, OpenBoneBoundingBox |
Bounding-box queries: instance.boundingBoxes() (see below) |
OpenBoneDrawItemView / OpenBoneDrawItem |
Draw item (zero-copy view or copy): slot, attachment, kind, blend, texture, vertices, uvs, indices, colour, dark, per-vertex colours, clip, text, stroke |
OpenBoneEvent, OpenBoneAudioCommand, OpenBoneValue, OpenBoneBone, OpenBoneSlot |
Value types |
OpenBoneViewport, OpenBoneRenderMesh |
Framing (skeleton ↔ view, NDC) and the batched GPU-ready mesh |
OpenBoneMetalRenderer, OpenBoneView, OpenBonePlayer |
Metal renderer, UIKit / AppKit view, SwiftUI view (Apple platforms) |
OpenBoneAudio |
Plays audio commands with AVFoundation (Apple platforms); handle(_:), stopAll(fade:), volume |
OpenBoneAudioMixer, OpenBoneAudioOutput |
Platform-neutral gain, voice and fade logic, and the protocol of what plays the voices |
OpenBoneRuntime |
apiVersion, version, hasScripts |
OpenBoneError |
Thrown with the runtime’s message (ob_last_error()) |
Bounding boxes
Section titled “Bounding boxes”instance.boundingBoxes() returns an OpenBoneSkeletonBounds snapshot of the current pose (like Spine’s
SkeletonBounds; docs/runtime-spec.md §8.10): boxes (OpenBoneBoundingBox: index, slot, attachment, world
vertices as [Float], own bounds) for every slot in draw order whose bone is active and whose current attachment is
a bounding box, and aabb (nil with no box). Queries in skeleton space: aabbContainsPoint(x:y:), aabbIntersects(_:),
containsPoint(x:y:) / containsPointAll(x:y:), intersectsSegment(x1:y1:x2:y2:) / intersectsSegmentAll, and
intersectsBounds(_:) → (mine, theirs)? between two snapshots (for example two characters). Call update(_:) or take
a new snapshot after the pose changes.
inst.advance(dt)if let hit = inst.boundingBoxes().containsPoint(x: p.x, y: p.y) { print("hit \(hit.attachment)") }Path constraints with constantSpeed: false in the document place bones at the raw bezier parameter inside each
segment instead of by arc length (Spine’s “constant speed” off); unset or true keeps arc-length placement.
Memory: documents and instances own their C handles and free them in deinit. An instance keeps its document alive,
so a document is always freed after its instances. Callbacks are closures stored on the instance (beware of
capturing the instance strongly in its own callbacks — use [weak inst]). An instance is not thread-safe; different
instances may be used from different threads.
What was and wasn’t tested
Section titled “What was and wasn’t tested”Tested here (Linux x86_64, Swift 6.0.3, Ubuntu 22.04):
swift buildandswift test: all 88 conformance cases pass (including the bounding-box queries ofbounds.json), plus the API, render-geometry and audio tests (43 tests);- the conformance comparison was mutation-checked (perturbed vertices and states make every case fail);
swift test --sanitize=address: no errors; LeakSanitizer reports only allocations made inside the Swift runtime / XCTest themselves (none from the C / C++ runtime or this package’s objects);testLifetimeschecks documents and instances are released;- use as a versioned dependency: a consumer package depending on a tagged clone by URL resolves
v0.2.0and builds with no unsafe-flags error (CI repeats this on Linux), and that the sample package resolves and compiles (on Linux it compiles to a stub, as SwiftUI is missing).
Not tested: everything that needs Apple frameworks. MetalRenderer.swift, OpenBoneView.swift,
OpenBonePlayer.swift, Audio/OpenBoneAudio.swift (AVFoundation) and the sample’s UI are behind
#if canImport(Metal/MetalKit/SwiftUI/AVFoundation/…) and were neither compiled nor run — there was no Apple hardware
or SDK. Expect small compile fixes on first build in Xcode, check rendering by eye (blend modes, clipping, two-colour
tint, premultiplied pages, text placement, touch mapping) against the editor, and listen to the sounds (the button
and campfire examples). The ARM64 path (Apple Silicon, iOS devices) is likewise untested here, although the C++
runtime is compiled without FMA contraction for it.
OpenBone is MIT licensed and made by UXCentury. The example art, voices and sounds are credited in the repository.