Swiftでビジュアルプログラミング環境を自作して、インタラクティブプログラミングをサクッと楽しめるのか!?

鶴田 真也
118

この記事は RECRUIT MARKETING PARTNERS Advent Calendar 2018 の投稿記事です。

みなさまこんにちは。リクルートマーケティングパートナーズの鶴田です。普段の業務では スタディサプリENGLISHiOS エンジニアをしています。以前は スタディサプリ進路 チームにて、iOS エンジニアやデザイナなどをやっていました。
(てきとうなレジュメ 📝 )

デバイスやメディアインスタレーションなど、かたちのあるモノをつくることが好きです。自動車も好きで、実車は古~いクラウンやAMGを愛でています。一方ボロボロのラリーカーをゲットしてラリーをやるのも昔からの夢です。

ところでみなさん、ノードエディタは好きですか

ノードエディタとは1)ぼくの知っている限りは通称に過ぎず、安定したデファクトスタンダードな名称が未だにないと思うのですが機能をもったパーツを線でつないでプログラミングする、いわゆる ビジュアルプログラミング 環境の一種です。

ノードエディタの例
たとえばこんなの (これは Blender 内蔵のノードエディタです)

今回はこいつを Swift/UIKit でつくる話をします。

そのまえに: ノードエディタってなんぞや

初心者向けプログラミング環境?

ノードエディタなどのビジュアルプログラミングは、初心者向けプログラミング環境として紹介されることが多いのではないでしょうか。

例えば Scratch は(いきなりノードエディタではないビジュアルプログラミング環境で申し訳ないですが…)、GUIでブロックを上下に繋げていくことで、プログラミング言語の文法を覚えて書くことなく簡単にフロー処理をプログラムすることができる処理系です。有名なところでは、LEGOのマインドストームシリーズのプログラミングなんかにも用いられていますね2)NQCとかも懐かしいですね

ScratchのUI。左に見えるブロックを中央の画面で組み立てていく

ビジュアルプログラミング環境は、このように図形を積み木のように積み上げるといったメタファーや機器同士をケーブルで繋ぐといったメタファーがとられる場合が多いです。そのため、ドメイン限定的な簡単なことならば、文字列で記述するプログラミングスタイルに比べて様々なプログラミング言語を覚えずともさくっと実装できるのが特徴です。

ちなみに発想的には、データ (電気信号) が様々なノード (細胞) を通って加工されていく神経細胞や、それを模したパーセプトロン、ひいてはDNNも構造的には同じようなものになっています。つまりノードエディタは、様々な関数が入れ子になった高階関数のネットワークであり、計算グラフです。

おなじみ神経細胞くん。ノードエディタはこのような計算グラフといえる

ノードエディタの実際

さて、初心者向けと見なされがちなビジュアルプログラミングですが、実は世の中的にはプロユースのほうが浸透しています。

例えば3DCGプロダクションの現場で存在感が増している Houdini などは、独自のノードエディタを内蔵しています。例としては、モアナと伝説の海のようなディズニー映画 (解説記事) や、きゃりーぱみゅぱみゅの 原宿いやほい などのようなMV (解説記事) でも、その成果を目にすることが増えてきました。

もっと身近だと、ゲームプロダクション環境の UnityUnreal Engine なども、シーン構築やキャラクター、シェーダープログラミングのためにノードエディタを搭載しています。

メディアアートや音楽の世界でも、ノードエディタによるビジュアルプログラミング環境は活躍しています。出自的に音響処理に強い Max や、映像処理に強い TouchDesigner がその典型ですね。TouchDesignerについては、元Houdiniの中の人が作っているため、さもありなんといった感じです。

向き不向き

プログラミング言語を覚えなくてよいといったことから、いろいろラクそうで良いことづくめにおもえるビジュアルプログラミングですが、適材適所という言葉があるように、向き不向きがあります。向いているのは主に以下のような場面ではないでしょうか。

  • ライブやインスタレーション、映像・音楽制作といったような、ライブ感のある制作シーンにおいて、リアルタイムで臨機応変にコード片の接続を構築する必要がある場面3)もちろん文字列ベースでも、音響は SuperColliderなど、ビジュアルはWebGLのシェーダーのライブコーディングなんかも流行っていますが…!
  • ゲームの部分的なプログラミングや、シェーダなど、言語で書くよりも臨機応変なプロトタイピングが求められる場面
  • 初心者向けの最初のプログラミング教育など、あえてプログラミング言語のシンタックスのことを考えずに概念を学びたい場面

逆に、向いていないのは以下のような場合だと思っています。

  • 配布することが目的のアプリケーション4)配布することが目的のアプリケーションは、シンプルに目的に特化したパッケージングにすることが望ましいから。100徳ナイフを作ってはならないというはなし
  • 特定のデバイスのみに最適化したアプリとして配布する場合や、自作ではないビジュアルプログラミング環境を使っていて、そこに含まれていない複雑なカスタム処理をしたい場合

一般的な観点からの向き不向きは、おなじみ 上杉さんQuoraに分かりやすくまとめてくれています

また、ここまであえて忘れたかのように触れていませんでしたが、ProcessingopenFrameworks (oF) 、Cinder5)後発ですがoFを差し置いてカンヌをとって物議を醸しましたね…でも箱出しで壊れているoFと比べるとじつは使いやすかったり洗練されていたりします のような、普通にテキストのプログラミング言語を用いるインタラクティブプログラミング環境ももちろんありますし、そちらのほうが有名です。これらはもはや「とりあえずみんな使っている」という普及レベルを通り過ぎてから久しいです。

このような向き不向きや多彩な選択肢があるなかで、なぜあえてノードエディタを使うのでしょうか。個人的な想いとしては、(あくまでインタラクティブな現場においてはですが)oF や Cinder 等は、いくらコードをライブラリ化しても結局組み合わせ方は書き捨てになるのを繰り返し続けてきて、それがあまり良いこととは思えなかったからです。以前書いたコードを開いて、ここどうしてたっけ…と探すことを人間は本当は望んでいないでしょう。

この文脈で実現したいのは、アプリケーションの中にアプリケーションがあるというイメージです。つまり、複数の自作アプリケーションをリアルタイムに自由に組み替えて使えるようなイメージを実現したいということなのです。

なぜ自作するのか

先項でも少し触れましたが、ノードエディタやそうでないものも含めたクリエイティブコーディング環境は、結局のところ新規の機能やフレームワークから外れる試みをしようとすると当たり前ですが、フルスクラッチになります。フルスクラッチになるわりにはフレームワークにある程度ロックインされたコードを書くことになるので、結局は DRY の原則に反するコードをライブや舞台の案件ごとに書き捨てる羽目になることが多く、再利用するためにまたそのコードをエディタで開きたくないという面倒くさがり特有の事情もあります。

そこで、

  • タッチUIでリアクティブにデータをライブ加工してインスタレーション等に使用できること
  • 2度と「同じ実装のちょっとした派生の実装」を繰り返さずに使えること
  • 最新機能や典型機能のサンプルコード集になり、それが実用的なパーツとしてどんな場面でも使えるようになること
  • 自分の好きなときに新機能の追加やメンテナンスができること (望んだ機能が今日、明日実装されて欲しい)

といった希望を満たすために、じゃあちょっくら自作しますかっ! ということです! ✌️
自作することで、これまでさわったことのないレイヤーも強制的に触ることになったり、後方互換性のないAPIやライブラリの更新等があって動かなくなった場合に強制的にどの分野の何が新しくなったのか知って実装し直すことができるなど、キャッチアップが必須のプログラマという生き方にとってはいいことずくめです(たぶん)。

…それ、 TouchDesigner とか Unity とかがじつは結局最強じゃね?? とまだおもうかもしれないですが、ちがうんだ、iPad Pro とかで、正直なタッチインターフェイスで現場で運用するのが夢なんだ…!! つまりそういうことです(あと大抵既存のものは通信が弱いなどもあります)。

ということで、今回はそのさわりだけでも作ってみましょう! 🙋

参考文献 (一例)

かんがえること

レッツフルスクラッチ! ノードエディタですので、まずノードを作ったり繋げたりできる必要がありますね。以下のような機能があれば良さそうです。

  • モデル
    • ノードタイプ (どんなノードの種類があるのか) の定義
    • ノードのIO
  • ビュー
    •  レイアウト
      • ノードタイプ定義からノード選択画面を生成
      • ノード選択画面から追加ノードを選択
      • ノードの配置
      • ノードの移動
      • ノードの削除
    • 機能
      • ノードの入出力
      • ノード間の接続
      • ノードのパラメタ
      • ノード自身の機能
      • ノード間の値の伝播

先も触れましたが、ノードエディタは計算グラフです。つまりどう考えても Rx と相性が良さそうですね。使っていきましょう。

他にもノードの実装では以下の事を気をつける必要がありそうです(いくつかは未実装)。

  • IOのタップエリアを十分に確保する
  • ノードのIOは数が増えても良いように上下にスタックする
  • Inputからはワイヤーを出せないようにする
  • Inputをタップすると接続されているワイヤーを削除する
  • ノードを削除したときにワイヤーと接続関係も削除する
  • ノードIOの型の包含関係を許可する (ArrayAny)
  • Inputには1つだけしかOutputを挿せないようにする
  • Outputを自ノードのInputに繋げないようにする
  • ノードのIOを折りたためるようにする
  • ノードをダブルタップでコンテキストメニューを表示する (削除等)

ノードとしてはとりあえず最低限、本当はこれだけではショボいですが、以下の様なものがあればやりたいことが実現できるかの確認だけはできそうです。今回はこの中の太字のビジュアル系の触りだけ実装してみましょう。

  • シグナル
    • スライダー
  • ビジュアル
    • シーン
      • 3次元シーン
      • 2次元シーン
    • オブジェクト
    • 座標
      • 2次元1
      • 2次元複数点
      • 2次元矩形グリッド
      • 2次元円形グリッド
      • 2次元ノイズ
      • 3次元1
      • 3次元複数点
      • 3次元箱グリッド
      • 3次元球グリッド
      • 3次元ノイズ
    • デバッグプロッタ
      • 2次元プロッタ
      • 3次元プロッタ
  • サウンド
    • 音源入力
      • マイク
      • 音源ファイル
    • 音源出力
    • FFT
  • モニタ
    • デバッグコンソール
    • シーンプレビューモニタ

また、以下のような機能も忘れてはいけませんが、今回は記事の対象外とします。

  • エディタレイアウト
    • ノードエディタ領域の自動拡縮
    • ノードエディタのスクロール
    • ノードエディタの表示拡縮
    • ノードのグループ化
    • ノードの衝突判定・アラインメント
  • 復元
    • ノードグラフのシリアライズ (jsonで型と接続情報のみ)
    • ノードグラフのデシリアライズ (jsonで型と接続情報のみ)
    • ノードグラフのシリアライズ (独自ファイルで型と接続情報のみ)
    • ノードグラフのデシリアライズ (独自ファイルで型と接続情報のみ)
    • ノードエディタの位置復元

つくってみよう

突然ですが、以下のようなコードを書けば、

class SliderNodeViewController: NodeViewController {

    override var size: CGSize { return CGSize(width: 320, height: 60) }

    // MARK: - Private Properties

    private var nodeOutput: NodeOutput!

    private let disposeBag = DisposeBag()

    // MARK: - Outlets

    @IBOutlet private weak var slider: UISlider!

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        self.setupIO()
        self.setupContents()
        self.setupConnections()
    }
}

// MARK: - IO

extension SliderNodeViewController {

    // MARK: - Private Methods

    private func setupIO() {
        self.nodeOutput = NodeOutput(valueType: Double.self)
        self.addNodeOutput(self.nodeOutput)
    }
}

// MARK: - Contents

extension SliderNodeViewController {

    // MARK: - Private Methods

    private func setupContents() {
        self.slider.minimumValue = 0.0
        self.slider.maximumValue = 20.0
    }
}

// MARK: - Connections

extension SliderNodeViewController {

    // MARK: - Private Methods

    private func setupConnections() {
        self.slider.rx.value
            .asObservable()
            .map { Double($0) }
            .bind(to: self.nodeOutput.valueRelay)
            .disposed(by: self.disposeBag)
    }
}

以下のようなノードが出現して使えたら楽ちんで嬉しいですね。

スライダーノード
スライダーノード

ポイントは、NodeViewControllerを継承すること、I/Oを作成してそれを設定するメソッドを叩くこと、I/Oと連携するノード内部の処理を実装すること、この3つだけです!

そのための登場人物は

  • ライブラリ
    • ノード (ViewはViewControllerです)
      • ノードタイプ
      • ノードI/O
    • ノードエディタ (ViewはViewControllerです)
      • ノードの追加配置
      • ノードの位置更新
      • ノード間のワイヤリング
      • ワイヤー描画の更新
    • ワイヤー
      • ドラフトワイヤーの描画
      • リアルワイヤーの描画
      • 両端ノード情報の保持
  • アプリケーション
    • カスタムノード

とします。

解説すると、今回作成したライブラリ層の

  • ノードエディタの上に複数のノードとワイヤーが乗っかる
  • それらが自身でタッチイベント等を拾いつつも
  • ノード間のワイヤリングの処理などをノードエディタが取り持つ

といったような構造になっています。ちなみに ViewController の上に ViewController を乗っけているのは表現力の担保のためです。

さきほどのような簡単に書けるノードは上記でいうところのアプリケーション層のカスタムノードにあたり、ユーザ (つまりプログラマーたるぼく) は、ノードがどんな仕組みで描画されたりドラッグに追従したりワイヤーで繋いだりされているか(そこは上記のライブラリ層が担当)を全く気にすることなく、自作の機能を持つノードの開発に集中できるという構造になります!6)とはいえ今回に限ってはライブラリ層の実装から始めているため、そこを気にすることは何も無いというのは大嘘なのですが… これを使う未来の僕が楽になるという意味です!😂

さて、ではそういったことができるかどうかという Proof-of-concept の為に、ざっくりざっくりと実装してみます。

ノードタイプ

まずはノードを作って配置したくなりますね。しかしさらにその前に「どんなノードがあるかを定義したら自動的にノード一覧のUIが組み上がって表示され、選択すればそのノードが配置される」といったことができれば楽ですね。ノードタイプを「とりあえずこんなのがあればいいやろ」というノリで定義してみます。

protocol NodeViewControllerNamable {
    var suffix: String { get }
}

extension NodeViewControllerNamable {
    var suffix: String { return "NodeViewController" }
}

// MARK: - NodeType

enum NodeType: String, CaseIterable {
    case signal
    case geometry
    case monitor

    var name: String {
        return self.rawValue.capitalized
    }

    var index: Int {
        return NodeType.allCases.firstIndex(of: self)!
    }
}
extension NodeType {
    enum Signal: String, CaseIterable, NodeViewControllerNamable {
        case slider

        var name: String {
            return self.rawValue.capitalized
        }

        var index: Int {
            return Signal.allCases.firstIndex(of: self)!
        }

        var viewControllerName: String {
            return "\(self.name.capitalized)\(self.suffix)"
        }
    }
}
extension NodeType {
    enum Geometry: String, CaseIterable, NodeViewControllerNamable {
        case circle
        case ellipse
        case sphere
        case point3

        var name: String {
            return self.rawValue.capitalized
        }

        var index: Int {
            return Geometry.allCases.firstIndex(of: self)!
        }

        var viewControllerName: String {
            return "\(self.name.capitalized)\(self.suffix)"
        }
    }
}
extension NodeType {
    enum Monitor: String, CaseIterable, NodeViewControllerNamable {
        case debug
        case scene

        var name: String {
            return self.rawValue.capitalized
        }

        var index: Int {
            return Monitor.allCases.firstIndex(of: self)!
        }

        var viewControllerName: String {
            return "\(self.name.capitalized)\(self.suffix)"
        }
    }
}

ここで enum を読み取って一覧を生成するモーダルを予め用意しておきました。表示してみましょう。

ノードセレクタによるノード追加
ノードセレクタによるノード追加
さきほど定義したノードが一覧で表示されています。よいですね!

ノードI/O

ノードは別のノードに繋くことができ、自身の内部で処理した値を次のノードに流してやる必要があります。ノードのI/Oを考えましょう。

まず大前提としてノードのI/Oは接続可能性がチェックされ、使用時に何の前提知識がなくともどのノードがどのノードに繋ぐことが可能であるのか一目瞭然にUIがハイライトされればとても嬉しいです。そのために、Output の場合は自身が流す値の型、Input の場合は自身が受け取る値の型を宣言しておきましょう。そう、せっかく Swift なので、タイプチェックをしようということです。…と書くとガチっぽいですが、今回はちょっと手抜きで、型を文字列に変換して保持しておき、それを型チェックに利用するようにしましょう。

ではI/Oそれぞれをさっとみていきましょう。

ノードの Input はデータを受け取り、ノード内部における処理のためにデータを受け取ったことをノード内部に通知してやると良さそうです。

final class NodeInput: NodeIO {

    // MARK: - Public Properties

    let uuid = UUID()

    var valueTypeString: String
    var isValueArray: Bool { return self.valueTypeString.contains("Array") }

    var valueRelay = PublishRelay<Any>()

    // MARK: - Lifecycle

    init(valueType: Any.Type) {
        self.valueTypeString = String(describing: valueType)
    }
}

extension NodeInput: Equatable {
    static func == (lhs: NodeInput, rhs: NodeInput) -> Bool {
        return lhs.uuid == rhs.uuid
    }
}

ノードの Output は、自身の内部で加工されたデータを、自身に接続された別のノードの Input に流してやればよさそうです。このとき、相手ノードの Input が受け付ける型を満たしているか確認して、適切に型変換してから流す必要もあります。

final class NodeOutput {

    // MARK: - Public Properties

    var valueTypeString: String
    var isValueArray: Bool { return self.valueTypeString.contains("Array") }

    let valueRelay = PublishRelay<Any>()

    var connectedInputs: [NodeInput] = []

    // MARK: - Private Properties

    private var connectionDisposable: Disposable!

    // MARK: - Lifecycle

    init(valueType: Any.Type) {
        self.valueTypeString = String(describing: valueType)
    }

    // MARK: - Public Methods

    func connect(to input: NodeInput) {
        self.connectedInputs.append(input)

        self.connectionDisposable = self.valueRelay
            .asObservable()
            .subscribe(onNext: { value in
                _ = self.connectedInputs.map { input in
                    if self.isValueArray && input.isValueArray {
                        input.valueRelay.accept(value)
                    } else if !self.isValueArray && !input.isValueArray {
                        input.valueRelay.accept(value)
                    } else if !self.isValueArray && input.isValueArray {
                        input.valueRelay.accept([value])
                    } else {
                        return
                    }
                }
            })
    }

    func disconnect(from input: NodeInput) {
        guard let index = self.connectedInputs.firstIndex(of: input) else { return }
        self.connectedInputs.remove(at: index)
        self.connectionDisposable.dispose()
    }
}

このようなI/Oをもったノードを実装すれば、機能をもったノード同士を接続してプログラミングできそうですね!

以上はI/Oの機能であり、ビューはもう少し複雑ですが今回は省略します。例えば I/O ビューは自身がドラッグされた場合、ワイヤーを生やしてノード同士を接続するといったような処理をノードエディタに移管します。

ノード

ノードは先ほど作ったI/O (を持ったI/Oビュー) を持ち、ノード独自の機能を適用して、値を加工して流します。また、ノードは自身がドラッグされた場合に適切にワイヤー位置を更新する処理を行います。

最初に挙げたような自分でつくるカスタムノードにおいては、あらかじめクラス名と同名のStoryboardでオリジナルのUIをデザインしておくことで、そのUIを邪魔せずに自動的にI/Oビューが生え、ノードエディタの仕組みを全く意識せずにオリジナルのUIを持ったノードを作ることができるように、あらかじめ実装してあります!

ノードのViewの実装は、コード量が多い割には今回の本質ではないため省略します。

ワイヤー

ノードのI/Oから引っ張り出してパスを描画できるような実装をとりあえず雑にやってみましょう。イイカンジのスプライン曲線にしたりするのはまた今度!

class WireView: UIView {

    // MARK: - Public Properties

    var nodeOutputPortView: NodeOutputPortView!
    var nodeInputPortView: NodeInputPortView!

    // MARK: - Private Properties

    private let disposeBag = DisposeBag()

    private var nodeOutputPortViewCenter: CGPoint!
    private var nodeInputPortViewCenter: CGPoint!

    private var bezierStartPoint = CGPoint.zero
    private var bezierEndPoint = CGPoint.zero

    // MARK: - Lifecycle

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    private override init(frame: CGRect) {
        super.init(frame: frame)
    }

    convenience init(output: NodeOutputPortView, input: NodeInputPortView) {
        self.init(frame: CGRect.zero)
        self.isOpaque = false
        self.nodeOutputPortView = output
        self.nodeInputPortView = input
        self.nodeOutputPortViewCenter = output.center
        self.nodeInputPortViewCenter = input.center
        self.setup()
        self.update()
    }

    override func draw(_ rect: CGRect) {
        let path = UIBezierPath()
        path.lineWidth = 2
        #colorLiteral(red: 0.8, green: 0.8, blue: 0.8, alpha: 1).setStroke()

        path.move(to: self.bezierStartPoint)
        path.addLine(to: self.bezierEndPoint)

        path.stroke()
    }

    // MARK: - Private Methods

    private func setup() {
        self.nodeOutputPortView.centerOnEditorRelay
            .asObservable()
            .subscribe(onNext: { point in
                self.nodeOutputPortViewCenter = point
                self.update()
            })
            .disposed(by: self.disposeBag)

        self.nodeInputPortView.centerOnEditorRelay
            .asObservable()
            .subscribe(onNext: { point in
                self.nodeInputPortViewCenter = point
                self.update()
            })
            .disposed(by: self.disposeBag)
    }

    private func update() {
        if self.nodeOutputPortViewCenter.x > self.nodeInputPortViewCenter.x{
            if self.nodeOutputPortViewCenter.y > self.nodeInputPortViewCenter.y {
                self.frame = CGRect(
                    x: self.nodeOutputPortViewCenter.x,
                    y: self.nodeOutputPortViewCenter.y,
                    width: self.nodeInputPortViewCenter.x - self.nodeOutputPortViewCenter.x,
                    height: self.nodeInputPortViewCenter.y - self.nodeOutputPortViewCenter.y
                )

                self.bezierStartPoint = CGPoint.zero
                self.bezierEndPoint = CGPoint(
                    x: self.frame.width,
                    y: self.frame.height
                )
            } else {
                self.frame = CGRect(
                    x: self.nodeOutputPortViewCenter.x,
                    y: self.nodeInputPortViewCenter.y,
                    width: self.nodeInputPortViewCenter.x - self.nodeOutputPortViewCenter.x,
                    height: self.nodeOutputPortViewCenter.y - self.nodeInputPortViewCenter.y
                )

                self.bezierStartPoint = CGPoint(
                    x: 0,
                    y: self.frame.height
                )
                self.bezierEndPoint = CGPoint(
                    x: self.frame.width,
                    y: 0
                )
            }
        } else {
            if self.nodeOutputPortViewCenter.y > self.nodeInputPortViewCenter.y {
                self.frame = CGRect(
                    x: self.nodeInputPortViewCenter.x,
                    y: self.nodeOutputPortViewCenter.y,
                    width: self.nodeOutputPortViewCenter.x - self.nodeInputPortViewCenter.x,
                    height: self.nodeInputPortViewCenter.y - self.nodeOutputPortViewCenter.y
                )

                self.bezierStartPoint = CGPoint(
                    x: self.frame.width,
                    y: 0
                )
                self.bezierEndPoint = CGPoint(
                    x: 0,
                    y: self.frame.height
                )
            } else {
                self.frame = CGRect(
                    x: self.nodeInputPortViewCenter.x,
                    y: self.nodeInputPortViewCenter.y,
                    width: self.nodeOutputPortViewCenter.x - self.nodeInputPortViewCenter.x,
                    height: self.nodeOutputPortViewCenter.y - self.nodeInputPortViewCenter.y
                )

                self.bezierStartPoint = CGPoint(
                    x: self.frame.width,
                    y: self.frame.height
                )
                self.bezierEndPoint = CGPoint.zero
            }
        }

        self.setNeedsDisplay()
    }
}

I/Oをドラッグしてみましょう。

ワイヤーうにょーん
ワイヤーうにょーん
ワイヤーが出てきました!! ☺️

ノードエディタ

ノードやワイヤーを乗っけてそれらを取り持つ屋台骨です。ノードの追加・配置、ノードの位置更新、ノード間のワイヤリングやワイヤー描画の更新といった機能を実装します。

こちらの実装に関しても殆どがViewのコードで、量が多い割には本質ではないため省略します。

ここまでやって初めてノードが置けて接続でき、引っ張っても適切にワイヤーがくっついてくるようになりました! 例えば、(後で実装しますが)試しにスライダーとデバッグノードを接続してみましょう。

ノードの接続とデバッグ
ノードの接続とデバッグ
スライダーを動かすと、デバッグノードを介して、コンソールに数値が流れているのがわかります。良いですね!!

ちなみに、ノードとワイヤーは切っても切れない(切れるんですが…)密接な関係にあるので、ノードを消したときにワイヤーも消えたり、ワイヤーのみ適切に削除したりできる必要があります。

ノードの削除とワイヤーの自動削除
ノードの削除とワイヤーの自動削除
ワイヤーのみ削除
ワイヤーのみ削除
よさそうですね!

ちょっとフライングですが、タイプチェックも正しく動いてるか見てみましょう!

適切なI/Oにのみ接続できる (接続可能なI/Oはハイライトされる。接続不可能なI/Oに繋げようとしても繋げられない)
適切なI/Oにのみ接続できる (接続可能なI/Oはハイライトされる。接続不可能なI/Oに繋げようとしても繋げられない)
Outputからワイヤーを引き出した瞬間に接続可能なInputがハイライトされ、どこに何を繋げば動くのかが一目瞭然です。これで各ノードのI/Oの特性を覚えずとも、とりあえず配置して使えるか試してみるのが楽になりましたね! 👏

つかってみよう

さていよいよです。ためしに以下のようなノードを実装してみましょう!! PoCなのであえてRxの使い方が乱暴なのは見なかったフリでお願いします!!7)PoCにおいて本質でないエラーハンドリングや副作用などをがっつり無視しています

ちなみに先ほどのノードの節でも触れたように、以下のような自分でつくるカスタムノードにおいては、あらかじめクラス名と同名の Storyboard でオリジナルのUIをデザインしておくことで、そのUIを邪魔せずに自動的にI/Oビューが生え、ノードエディタの仕組みを意識せずに、オリジナルUIを持ったノードが作成できるようお膳立てしています。

スライダー

class SliderNodeViewController: NodeViewController {

    override var size: CGSize { return CGSize(width: 320, height: 60) }

    // MARK: - Private Properties

    private var nodeOutput: NodeOutput!

    private let disposeBag = DisposeBag()

    // MARK: - Outlets

    @IBOutlet private weak var slider: UISlider!

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        self.setupIO()
        self.setupContents()
        self.setupConnections()
    }
}

// MARK: - IO

extension SliderNodeViewController {

    // MARK: - Private Methods

    private func setupIO() {
        self.nodeOutput = NodeOutput(valueType: Double.self)
        self.addNodeOutput(self.nodeOutput)
    }
}

// MARK: - Contents

extension SliderNodeViewController {

    // MARK: - Private Methods

    private func setupContents() {
        self.slider.minimumValue = 0.0
        self.slider.maximumValue = 20.0
    }
}

// MARK: - Connections

extension SliderNodeViewController {

    // MARK: - Private Methods

    private func setupConnections() {
        self.slider.rx.value
            .asObservable()
            .map { Double($0) }
            .bind(to: self.nodeOutput.valueRelay)
            .disposed(by: self.disposeBag)
    }
}

3次元ベクトル

class Point3NodeViewController: NodeViewController {

    override var size: CGSize { return Configuration.Layout.Size.Node.regular }

    // MARK: - Private Properties

    private var xInput: NodeInput!
    private var yInput: NodeInput!
    private var zInput: NodeInput!
    private var vector3Output: NodeOutput!

    private let disposeBag = DisposeBag()

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        self.setupIO()
        self.setupConnections()
    }
}

// MARK: - IO

extension Point3NodeViewController {

    // MARK: - Private Methods

    private func setupIO() {
        self.xInput = NodeInput(valueType: Double.self)
        self.addNodeInput(self.xInput)

        self.yInput = NodeInput(valueType: Double.self)
        self.addNodeInput(self.yInput)

        self.zInput = NodeInput(valueType: Double.self)
        self.addNodeInput(self.zInput)

        self.vector3Output = NodeOutput(valueType: SCNVector3.self)
        self.addNodeOutput(self.vector3Output)
    }
}

// MARK: - Connections

extension Point3NodeViewController {

    // MARK: - Private Methods

    private func setupConnections() {
        Observable.combineLatest(
            self.xInput.valueRelay.asObservable(),
            self.yInput.valueRelay.asObservable(),
            self.zInput.valueRelay.asObservable()
        ) { x, y, z -> SCNVector3 in
            guard
                let x = x as? Double,
                let y = y as? Double,
                let z = z as? Double
            else {
                throw NodeEditorError.typeResolveError
            }
            return SCNVector3(x, y, z)
        }
        .bind(to: self.vector3Output.valueRelay)
        .disposed(by: self.disposeBag)
    }
}

class SphereNodeViewController: NodeViewController {

    override var size: CGSize { return Configuration.Layout.Size.Node.regular }

    // MARK: - Private Properties

    private var positionsInput: NodeInput!
    private var radiiInput: NodeInput!
    private var spheresOutput: NodeOutput!

    private let disposeBag = DisposeBag()

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        self.setupIO()
        self.setupConnections()
    }
}

// MARK: - IO

extension SphereNodeViewController {

    // MARK: - Private Methods

    private func setupIO() {
        self.positionsInput = NodeInput(valueType: [SCNVector3].self)
        self.addNodeInput(self.positionsInput)

        self.radiiInput = NodeInput(valueType: [Double].self)
        self.addNodeInput(self.radiiInput)

        self.spheresOutput = NodeOutput(valueType: [SCNNode].self)
        self.addNodeOutput(self.spheresOutput)
    }
}

// MARK: - Connections

extension SphereNodeViewController {

    // MARK: - Private Methods

    private func setupConnections() {
        Observable.combineLatest(
            self.positionsInput.valueRelay.asObservable(),
            self.radiiInput.valueRelay.asObservable()
        ) { ps, rs -> (positions: [SCNVector3], radii: [Double]) in
            guard
                let positions = ps as? [SCNVector3],
                let radii = rs as? [Double],
                positions.count == radii.count
            else {
                return ([], [])
            }
            return (positions, radii)
        }
        .map { tuple -> [SCNNode] in
            let (positions, radii) = (tuple.positions, tuple.radii)
            var nodes: [SCNNode] = []
            for (index, position) in positions.enumerated() {
                let sphere = SCNSphere(radius: CGFloat(radii[index]))
                sphere.isGeodesic = true

                let sphereNode = SCNNode(geometry: sphere)
                sphereNode.position = position
                nodes.append(sphereNode)
            }
            return nodes
        }
        .bind(to: self.spheresOutput.valueRelay)
        .disposed(by: self.disposeBag)
    }
}

レンダリングビュー

class SceneNodeViewController: NodeViewController {

    override var size: CGSize { return CGSize(width: 320, height: 320) }

    // MARK: - Private Properties

    private var nodesInput: NodeInput!

    private let disposeBag = DisposeBag()

    private var scene = SCNScene()

    // MARK: - Outlets

    @IBOutlet private weak var scnView: SCNView!

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        self.setupIO()
        self.setupContents()
        self.setupConnections()
    }
}

// MARK: - IO

extension SceneNodeViewController {

    // MARK: - Private Methods

    private func setupIO() {
        self.nodesInput = NodeInput(valueType: [SCNNode].self)
        self.addNodeInput(self.nodesInput)
    }
}

// MARK: - Contents

extension SceneNodeViewController {

    // MARK: - Private Methods

    private func setupContents() {
        let camera = SCNCamera()
        let cameraNode = SCNNode()
        cameraNode.camera = camera
        cameraNode.position = SCNVector3(0, 0, 40)

        self.scene.rootNode.addChildNode(cameraNode)

        self.scnView.scene = self.scene
    }
}

// MARK: - Connections

extension SceneNodeViewController {

    // MARK: - Private Methods

    private func setupConnections() {
        self.nodesInput.valueRelay
            .asObservable()
            .subscribe(onNext: { value in
                guard let nodes = value as? [SCNNode] else { return }
                _ = nodes.map {
                    _ = self.scene.rootNode.childNodes.map { childNode in childNode.removeFromParentNode() }
                    self.scene.rootNode.addChildNode($0)
                }
            })
            .disposed(by: self.disposeBag)
    }
}

繋いでみましょう! どきどき…

ノードを接続してアプリケーションを作成
ノードを接続してアプリケーションを作成
スライダーを動かすと…

applicationapplication2

ふおおおおおおおお!! 💕💕💕

結論

タイトルの回収です。自作のノードエディタでサクッとインタラクティブプログラミングを楽しめました!! 🎉

型で縛られたI/Oと、Rxによるリアクティブな処理により、直感的にノードエディタが実装でき、簡単に利用できましたね。これでたとえば、自分で作ったデバイスなんかと接続するノードを自分でサクッと書けちゃいます! (これが普通のノードエディタだと地味にめんどくさい…!) ちなみに今回は実現可能性としてかなり素朴な Proof-of-Concept を作成するに留まりましたが、実務で使う際は以下のような機能が必要です!!!!

  • ノイズを取り入れたリアルタイムジェネラティヴ3DCGアニメーション
  • 外部3DCGモデル読込
  • 物理シミュレーション
  • DSP
  • デバイス/ネットワーク接続
  • 3DCGインスタレーション環境読込
  • 簡易インスタレーション環境作成
  • インスタレーション環境構成
  • インスタレーション環境リハーサル

夢が広がりますね! 今まで散々やっていたことではありますが、イチから自分用に実装しなおすとなると、楽しさもまたひとしおです。今回の記事で、Swiftで趣味でも実務向けにでもインタラクティブプログラミングすることへの心理的ハードルが下がったならば、うれしいです!

こちらの実装は引き続き頑張っていきたいと思います! それでは! 👋

脚注

脚注
1 ぼくの知っている限りは通称に過ぎず、安定したデファクトスタンダードな名称が未だにないと思うのですが
2 NQCとかも懐かしいですね
3 もちろん文字列ベースでも、音響は SuperColliderなど、ビジュアルはWebGLのシェーダーのライブコーディングなんかも流行っていますが…!
4 配布することが目的のアプリケーションは、シンプルに目的に特化したパッケージングにすることが望ましいから。100徳ナイフを作ってはならないというはなし
5 後発ですがoFを差し置いてカンヌをとって物議を醸しましたね…でも箱出しで壊れているoFと比べるとじつは使いやすかったり洗練されていたりします
6 とはいえ今回に限ってはライブラリ層の実装から始めているため、そこを気にすることは何も無いというのは大嘘なのですが… これを使う未来の僕が楽になるという意味です!😂
7 PoCにおいて本質でないエラーハンドリングや副作用などをがっつり無視しています