Swiftでメディアアートしたいからライブラリを作ってみた
はじめまして、Swiftがめちゃめちゃ大好きなiOSエンジニアです。
Zennの記事を書くのは初めてなので変な文章があっても気にしないでください。
Swiftでメディアアート的なことができないか色々試してみたので知見を共有します。
上のgif画像で表示されているものはすべて今回紹介するSwiftyCreativesライブラリで描画されています。
はじめに
Processingは、rect()、fill()などの関数を使用して、直感的に図形を描画できるプログラミング環境として知られています。そのシンプルさと表現力の高さから、メディアアートやデザイン分野で広く利用されています。しかし、Processingで作成した作品をWebやアプリに組み込むためには、様々な技術的な工夫が必要になります。
WebでProcessingのようなグラフィックを描画したい場合はp5.jsなどのライブラリを用いることで比較的簡単にメディアアート的な表現をWeb上に埋め込むことができます。
iOSアプリやmacOSアプリをSwiftでネイティブアプリとして実装している場合、p5.jsのような便利なライブラリは今のところ存在していないため、p5.jsで書いた作品をWebViewで表示したり、SceneKitを使ったりなど、色々な工夫が必要になります。
いろいろ試したのですが、どれもしっくりこなかったので自分で作ってしまおうと思い立ち、1年ほど前にSwiftyCreatives(https://github.com/yukiny0811/swifty-creatives)ライブラリを作成し公開しました。これはSwiftでProcessingのような文法を使って図形を描画することができるライブラリです。
開発から1年ほど経ってやっと動作が安定してきたので、先日v2.0.0をリリースしました。今回はこのSwiftyCreativesの開発に至った経緯などを交えつつ、このライブラリを使うことでどのような表現が可能になるか、そしてこのライブラリの内部実装などを紹介していきます。
SwiftyCreativesライブラリのコンセプト
- 高度な知識がなくても直感的に書ける(一番大事)
- それなりのパフォーマンスがある
- それなりに拡張性がある
余計な手間なく直感的に書ける
SwiftyCreatives以外にも、いくつかSwiftでクリエイティブコーディングをするためのライブラリは公開されています。
例えばSatinは、非常に高度なレンダリングエンジンを扱うことができ、物理ベースレンダリングなどにも対応している超高機能なライブラリです。しかし高機能な反面、長方形を一つ表示するだけでも数十行必要になってしまい、学習コスト・導入コストが高いことが欠点です。
SatinのExamplesを見ていただければどれだけ難しいかが伝わると思います
SwiftyCreativesでは、必要最小限のコードで図形が描画できるようになっており、図形の描画のためにレンダリングについての知識や数学の知識は必要ありません。
例えば、長方形を一つ描画するプログラムはこんな感じです。
import SwiftyCreatives
import SwiftUI
class MySketch: Sketch {
override func draw(encoder: SCEncoder) {
color(1, 1, 1, 1) // r,g,b,a
rect(0, 0, 0, 3, 3) // x,y,z,scaleX,scaleY
}
}
struct ContentView: View {
var body: some View {
SketchView(MySketch())
}
}
15行!!たったこれだけで白い長方形を描画することができます。
また、描画空間はデフォルトで3D空間になっており、マウスや指を使って自由に視点を移動させることができます。
それなりのパフォーマンスがある
SwiftyCreativesやSatin以外のSwift製クリエイティブコーディングライブラリを探してみると、大体がSceneKitのラッパーライブラリになっています。
SceneKitとは、Appleが公式で提供している3DCGライブラリです。Appleが提供しているため、それなりにドキュメントも充実しており、アプリ開発との親和性もそれなりに高いです。
しかし、やはり先程のSatinと同じように図形を描画するためのハードルが非常に高い上に、パフォーマンスにかなり問題があります。SceneKitは超高機能なAPIなので、シンプルな長方形を一つ描画するにも物体のマテリアルやテクスチャなど、大量の情報が必要になっています。そのため、描画に関する拡張性は非常に高いのですが、直方体を10000個とか描画したい、となるとかなり画面がカクついてしまいます。
SwiftProcessingとかは特にこれに当てはまります。(めっちゃいいライブラリではあるんですけどね)
SwiftyCreativesでは図形の描画にMetalを直接用いているため、非常に高速にレンダリング処理を行うことができています。
MetalはSceneKitなどの下位レイヤーで用いられているAPIで、iOS・macOSアプリを開発する際のグラフィックス処理はほぼすべてこのMetalが担っています。わかりやすい例を出すとUIImageViewの下ではMetalが動いています。Metalについての詳しい話はしないのですが、よりGPUのハードウェアに近い部分のAPIだと思っておいていただければ大丈夫です。
このMetalを直接使っているため、直方体10000個程度なら余裕で描画できる程度にはパフォーマンスが出ています。
拡張性がある
直感的に書けるライブラリにする、というのが第一目標なのですが、やはりある程度は拡張性がないと使い物になりません。
SwiftyCreativesは技術力があればそれなりに簡単に拡張できるように設計してあります。
例えば先程のサンプルコードにあったこちらoverride func draw(encoder: SCEncoder)
のコード。SCEncoderはMTLRenderCommandEncoderのtypealiasなので、実質これがあれば何でも追加でシーンにレンダリングすることができます。
また、PostProcessをかけたい場合は、override func postProcess(texture: MTLTexture, commandBuffer: MTLCommandBuffer)
というメソッドが準備されており、MTLCommandBufferが取得できるようになっているため、実質できないことはありません。(Metalに詳しい人向け)
また、後述しますが@SketchObject
というMacroも準備されていて、ライブラリを使う人がオリジナルのオブジェクトを定義できるようにもなっています。
使い方
さきほどちらっと紹介しましたが、SwiftyCreativesで図形を描画するときの最小コードはこちらです。今回はSwiftUIで実装していますが、UIKit用とvisionOS用にも実装方法が準備されています。
Sketch
クラスを継承したクラスを作り、draw
メソッドをoverrideすることで図形を描画できるようになります。
import SwiftyCreatives
import SwiftUI
class MySketch: Sketch {
override func draw(encoder: SCEncoder) {
//ここに図形描画処理を書く
}
}
struct ContentView: View {
var body: some View {
SketchView(MySketch())
}
}
描画できる図形一覧
line() //線
boldline() //太さのある線
box() //直方体
circle() //円
img() //画像
mesh() //メッシュ
model() //3Dモデル(いまのところobjのみ)
rect() //長方形
svg() //SVG画像
text() //文字
triangle() //三角形
一通り描画してみるとこんな感じです。
その他にもいろいろ描画できるものがあります。
特に面白いのはUIViewObjectで、Storyboard上で作成したUIViewのxibを読み込むことで3DシーンにそのViewをそのまま表示させることができます。
@IBAction
を使って関連付けしたボタンもちゃんと動作します。
下のgifでは、3D空間上に置いたUIViewのボタンをタップしてそのViewを回転させています。
長方形や直方体、画像などはHitTestも実装されているので、タップやマウスでHoverしたときの処理なども書くことができます。
PostProcessing
下のgifのように、Bloomを始めとしたさまざまなエフェクトをPostProcessとしてかけることができます。
下のコードはBloomをPostProcessとしてかけるサンプルです。めちゃめちゃシンプルにPostProcessをかけることができるのがわかると思います。
import SwiftyCreatives
import SwiftUI
final class Sample1: Sketch {
let bloomPostProcessor = BloomPostProcessor()
override func draw(encoder: SCEncoder) {
let count = 20
for i in 0..<count {
color(1, Float(i) / 40, 0, 0.5)
push {
rotateY(Float.pi * 2 / Float(count) * Float(i))
translate(10, 0, 0)
box(0, 0, 0, 1, 1, 1)
}
}
}
override func postProcess(texture: MTLTexture, commandBuffer: MTLCommandBuffer) {
bloomPostProcessor.dispatch(texture: texture, threshold: 0.1, intensity: 25, commandBuffer: commandBuffer)
}
}
struct Sample1View: View {
var body: some View {
SketchView(Sample1())
}
}
また、PostProcessは自作することもできます。@EMComputeShader
Macroを使うことによって、シェーダーパイプラインを全く書かずにComputeShaderを実装することができます。
今回はグレースケールフィルターを書いてみました。
@EMComputeShader
class MyPostProcessor {
var tex: MTLTexture?
var impl: String {
"float4 color = tex.read(gid);"
"color = 0.2989 * color.r + 0.5870 * color.g + 0.1140 * color.b;"
"tex.write(color, gid);"
}
var customMetalCode: String {
""
}
}
final class Sample1: Sketch {
let bloomPostProcessor = BloomPostProcessor()
let myPostProcessor = MyPostProcessor() //ここ追加
override func draw(encoder: SCEncoder) {
let count = 20
for i in 0..<count {
color(1, Float(i) / 40, 0, 0.5)
push {
rotateY(Float.pi * 2 / Float(count) * Float(i))
translate(10, 0, 0)
box(0, 0, 0, 1, 1, 1)
}
}
}
override func postProcess(texture: MTLTexture, commandBuffer: MTLCommandBuffer) {
bloomPostProcessor.dispatch(texture: texture, threshold: 0.1, intensity: 25, commandBuffer: commandBuffer)
//ここ追加
commandBuffer.compute { encoder in
self.myPostProcessor.tex = texture
self.myPostProcessor.dispatch(encoder, textureSizeReference: texture)
}
}
}
カスタムオブジェクト
@SketchObject
Macroを使うことによって、カスタムオブジェクトを定義できます。
このMacroをクラスにつけることによって、各種描画関数が使えるようになります。
@SketchObject
class MyBox {
var pos: f3
var col: f4
var scale: f3
init(pos: f3, col: f4, scale: f3) {
self.pos = pos
self.col = col
self.scale = scale
}
func draw() {
color(col)
box(pos, scale)
}
}
作ったオブジェクトはこのように使います。
SketchObjectマクロがdraw(encoder:customMatrix:)
を生やしてくれるので、それを使ってください。
let myBox = MyBox(pos: f3(0, 0, 0), col: f4(1, 0, 0, 1), scale: f3(1, 1, 1))
...
myBox.draw(encoder: encoder, customMatrix: getCustomMatrix())
Push・Pop
Processingなどクリエイティブコーディングには必須のPush・Popです。Processingでは下のコードのようにpushMatrix()
とpopMatrix()
を別に呼ばなければなりませんでした。しかし、これだとpushpopがネストしていくとどんどんコードの可読性が悪くなってしまっていました。
//processingのコード
pushMatrix()
rotate(0.5)
pushMatrix()
translate(20, 30)
pushMatrix()
rotate(0.3)
ellipse(10, 10, 20, 20)
popMatrix()
popMatrix()
popMatrix()
SwiftyCreativesでは、SwiftのTrailing Closureという言語仕様を使うことによってPushPopにインデントがついてめちゃめちゃ綺麗にかけるようになりました。個人的なお気に入り実装No1です。
//SwiftyCreativesのコード
push {
rotateY(0.5)
push {
translate(20, 30, 0)
push {
rotate(0.3)
circle(10, 10, 10, 10, 10)
}
}
}
ここまででいったん使い方の紹介はおしまいです!
ここからはSwiftyCreativesの内部実装について話していきますが、よかったらhttps://github.com/yukiny0811/swifty-creatives こちらスターつけてくれるとモチベが爆上がりします!
SwiftyCreativesの内部実装
気がついたらかなりコードかいてた。
パッケージ構造
1年ほど暇なときにゆっくり開発していたら気がついたら大きいライブラリになってきたので、実はパッケージを分割していたりします。
構造としてはこんな感じです。
- SwiftyCreativesライブラリ (本体)
- SwiftyCreativesモジュール
- SwiftyCreativesSoundモジュール
- SwiftyCreativesMacroモジュール
- FontVertexBuilderライブラリ
- FontVertexBuilderモジュール
- SVGVertexBuilderモジュール
- SimpleSimdSwiftライブラリ
- SwiftyCoreTextライブラリ
- EasyMetalShaderライブラリ
まずは小さいライブラリから紹介していきます。
SwiftyCoreText
https://github.com/yukiny0811/SwiftyCoreText
CoreTextのCTFontのWrapperです。CoreTextはC言語のAPIなので、Swiftからだとちょっと使いづらかったです。なので、CoreTextの中のCTFontだけでも.
って打ったら予測変換が出るようにしようと思って作りました。
let font = SwiftyCTFont(...)
let ascent = font.getAscent()
let ctFont = font.ctFont
SimpleSimdSwift
https://github.com/yukiny0811/SimpleSimdSwift
simd_float2とかsimd_float4x4とかいちいち書くのがめんどくさくなったので作りました。
simd_float2のかわりにf2、simd_float4x4のかわりにf4x4とかけるようになるだけの小さいライブラリです。あと座標変換行列の処理もこのライブラリに書いていたりします。
//中の実装の例
public extension f4x4 {
static func createTransform(_ x: Float, _ y: Float, _ z: Float) -> f4x4 {
return Self.init(
f4(1, 0, 0, 0),
f4(0, 1, 0, 0),
f4(0, 0, 1, 0),
f4(x, y, z, 1)
)
}
}
FontVertexBuilderライブラリ
https://github.com/yukiny0811/FontVertexBuilder
このライブラリがとっっっても大変でした。
SwiftyCreativesはMetalですべての処理を描画しているので、文字の描画のためにはフォントを頂点データに変換しなければなりません。このライブラリではフォント->Pathデータ->三角形分割->頂点データの流れを実装しています。
SVGの描画も全く同じアルゴリズムでやっているので、SVGVertexBuilderという名前で同じライブラリに別Targetとして組み込んじゃいました。
これが実装できるまで文字の描画ができなかったのでとっても苦労しました。
内部で先程紹介したSwiftyCoreText、SimpleSimdSwift、そした外部ライブラリとしてSVGPath、iGeometry、iShapeTriangulation、さらにフォント->Pathデータの変換にSatinで実装されていたアルゴリズムを一部使っています。外部ライブラリの作者の方ありがとうございます。
EasyMetalShaderライブラリ
https://github.com/yukiny0811/EasyMetalShader
Metalシェーダーを超簡単に書けるようになるライブラリです。
SwiftyCreativesではPostProcessorを書くときに使っています。
//先程のグレースケールPostProcessor
@EMComputeShader
class MyPostProcessor {
var tex: MTLTexture?
var impl: String {
"float4 color = tex.read(gid);"
"color = 0.2989 * color.r + 0.5870 * color.g + 0.1140 * color.b;"
"tex.write(color, gid);"
}
var customMetalCode: String {
""
}
}
先ほど消化したグレースケールのPostProcessorに使った@EMComputeShader
はこのライブラリが提供しているマクロです。
自画自賛になりますがこのライブラリがない生活にはもう戻れません。超便利です。
SwiftyCreativesSoundモジュール
Swiftでマイクの音を拾ったり、FFTしたりするためのモジュールです。
SwiftでFFTするの意外と大変。
SwiftyCreativesMacroモジュール
@SketchObject
マクロを提供しているモジュールです。
今後増える予定です。
SwiftyCreativesモジュール
本体です。
今まで紹介したライブラリが全てここに集まっています。
中の設計はこんな感じになってます。
- SwiftyCreatives
- Camera
- DrawUtils
- Extensions
- GeometryPresets
- Macros
- PostProcess
- Primitives
- PropertyWrappers
- Renderers
- Resources ここにmetalシェーダーのコードが入ってます。
- ShaderUtils
- Sketch
- Utils
- Views
metalシェーダーファイルをパッケージ内で扱う場合は、Package.swiftでresourceファイルとして定義する必要があります。今回はResourcesディレクトリをそのままresourceディレクトリとして設定しています。
let SwiftyCreatives = Target.target(
name: "SwiftyCreatives",
dependencies: [
...
],
path: "Sources/SwiftyCreatives",
resources: [
.process("Resources")
]
)
透明処理
透明処理はAppleのこちらのobj-cのドキュメントを参考にSwiftに変換して実装しました。
Vision Pro対応
Vision ProのImmersive Spaceでもレンダリングが動くようになっています。
visionOSだとMTKViewが使えなくてびっくりしました。
ImmersiveSpace(id: "ImmersiveSpace") {
CompositorLayer(configuration: ContentStageConfiguration()) { layerRenderer in
let renderer = RendererBase.BlendMode.normalBlend.getRenderer(sketch: SampleSketch(), layerRenderer: layerRenderer)
renderer.startRenderLoop()
}
}
MTKViewが使えない、カメラの位置がデバイスセンサー依存っていう関係で、既存のRendererをvisionOSと共存させるのは不可能だったのでvisionOS用に完全に新しくRendererを書き直しました。
今後の予定
レイトレーシングのRenderer書く予定です。
さいごに
https://github.com/yukiny0811/swifty-creatives ぜひ使ってみてください〜
こんなのあったら面白そう!というのがありましたらぜひコメントください!
Discussion