MercariARハッカソンで優勝したので技術的なまとめ


作品の内容やテーマ設定についてはこちらにまとめています。
Mercari AR Hackathon #0で優勝しました

この記事では技術的な観点でのまとめになります。

MercariARハッカソンについて

  • Mercariさん主催のAR特化ハッカソン
  • 優勝賞金50万円、準優勝10万円、各審査員賞2万円
  • 日程は2017/12/17(日)の1Day Hackathon
    今回は1Day Hackathonという形式のため事前の開発は有り(ただし当日も開発すること)というルール

  • 参加人数50名 19チーム
    個人チームとして参加させていただきました。

他のチームは2〜4人のチームを組んで参加されている方が多かったように見えました。個人参加は自分の他に4,5人。

作品の紹介

作品名:「AR一列つなげて消すパズル」

作品自体の詳細はMediumの方に記載していますMercari AR Hackathon #0で優勝しました

実装内容

  • iPhoneを床にかざして平面を検出すると、床に前後2段組みの「一列つなげて消すパズル」が出現
  • ARKitを利用しているのでフィールドの周りを歩いて違う方向から観察可能
  • BLEを利用して複数の端末での同時操作を実現

利用した技術

Swift

Unity技術は無かったので完全にSwift×ARKitで作成する方針にしています。
今回特に感じたのはSwiftの「良いゆるさ」でした。「きれいに型安全やnull安全に書くこともできるけれども、ちょっとタイプ数が増える」といった所を、「エラーが起きるかもしれないが省力化して書く」脇道があるのはハッカソンのように限られた時間とゴールに向けてコードを書くときに有効だったと思います。
もちろんきれいに書いた部分は後でリファクタリングがしやすくなるので、その辺の塩梅を開発者がコントロールできるのも良いですね。

ARKit

ARKitを選択すると必然的にSceneKitを利用することになります。ふだんはViewとViewControllerしか扱っていないので、SceneとNodeの関係を理解するのに苦戦しました。githubにサンプルコードが色々上がっていて助かりました。

ARKit自体を作り込んで超すごい3D表現をする…みたいな方向で勝負するのは無理筋だったので
「立法体や球だけで作れるものにする」という方針にして、Cubeが使えるようになった所まで学習した後は「それ以上の技術は使わない」と割り切っています。

BLE

観戦モードのためにiPhone間の通信部分で利用しています。
firebaseのリアルタイムDBを使うかBLEのを使うか迷ったのですが、良い機会なのでBLEを使い切って習得しておくのも良いだろうとBLEを選択。
子端末が接続に成功するとReadで現在のフィールド情報を全て取得し、以降は各アクションをNotifyで購読して動きを合わせています。
また観戦者側からの操作は単純にWriteでNotifyと同じデータを親端末側に送るように実装しています。

CI環境(BuddyBuild)

CIと配信のためにBuddyBuildを設定しましたが、結局個人参加でテストも書かなかったのであまり意味は無かったかも。

工夫した点

送信データを512バイト以下におさめる

BLEのReadは最大512バイトまでしか送ることが出来ません。
データを送るためのData型に変換する際にSwiftのCodableを使ってjsonのStringにして送っているのですが、フィールドに存在する全ブロックのデータ(色の種類も含む)を送ろうとすると500バイトを大きく超えてしまいます。

というわけで、どうにかこのフィールド全体の情報を512バイト以下に収める必要が出てきました。

設計

ブロックは13色+存在しない状態で14通りの情報を詰めれば良いので16進数=4bitあれば良いことが分かります。
ちょうど2ブロックで1バイト分の情報になるので(8bit = 1byte)前後のブロックをまとめてしまうことにします。
具体的には1バイトを16進数2つで表現したときに、前の16進数を手前のブロック、後ろの16進数を後ろのブロックの情報として扱うと簡単に実装できそうです。

これで10*20で200バイト程度で抑える目処が立ちました。

実装

ブロックの情報は3重配列に入れているだけなので簡単に

送信用のデータ構築.swift
  // z:y:x
  let gameField:   [[[Int]]]


  func encode() -> Data {
    let x = gameField[front].enumerated().flatMap { (y, yNodes) -> [UInt8] in
      yNodes.enumerated().flatMap { (x, xNode) -> UInt8 in
        return UInt8(xNode + gameField[back][y][x] << 4)
      }
    }
    return Data(bytes: x)
  }
受信データの展開.swift
  init(with data: Data) {
    var result:   [[[Int]]] = [[], []]
    var x                   = 0
    var frontRow: [Int]     = []
    var backRow:  [Int]     = []
    for node in [UInt8](data) {
      frontRow.append(Int(node) % 16)
      backRow.append(Int(node) >> 4)
      x = x + 1
      if x > engine.config.width + 1 {
        result[0].append(frontRow)
        result[1].append(backRow)

        x = 0
        frontRow = []
        backRow = []
      }
    }

ポイントはデータを追加するときに奥のブロックを4ビット左にシフトさせて和を取っていることと

送信.swift
        return UInt8(xNode + gameField[back][y][x] << 4)

受信側で逆に4ビット右にシフトさせることでそれぞれの値を取得してるところ

受信.swift
      frontRow.append(Int(node) % 16)
      backRow.append(Int(node) >> 4)

以上のやり方で通信容量を減らしてリアルタイムの通信を実現しています。