1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LiveNovelingのためのARシステム「Lexiphere」STYLYシーン実装編

1
Last updated at Posted at 2025-12-15

この記事はSTYLY Advent Calendar 2025の16日目の記事です。

「Lexiphere」シリーズ記事

概要編
Webページ構築編
STYLYシーン実装編(本稿)
Unity TextMesh Proのベストプラクティス

要約

バックエンドは概ね完成した。
あとはSTYLYシーンの開発だ。
AR演出と、Playmakerを使った実装の解説について説明。

AR演出について

当初は、うっすらBloomさせて執筆者の背後にフワッと浮かび上がるような演出を想定した。
LiveNovelingでは即興の執筆なので、筆者の文体はある程度予測できてもその内容がどういう雰囲気になるかまでは分からない
あまり過度な装飾をせずイイ感じにしたい、となるとやっぱり無難にBloomかな。

さて、ではどのように執筆者の背後に浮かび上がらせるのか?
ARで何かしらを表示する場合、STYLYでは幾つかの位置合わせパターンがある。

方式 概要
水平面検知 ARシーンのデフォルト挙動。任意の水平面を起点にシーンを表示
ImageTracking STYLYマーカーを基準にシーンを表示
Immersal 事前に作成したマップデータを基準にシーンを表示
WorldCanvas
(Geospatial)
街中での位置合わせ用
今回は不適格なので説明を割愛

今回の場合だと、ImageTrackingかImmersalかの2択になる。
ただどちらも難点がある。

まずImageTrackingだが、STYLYマーカーを掲示してそれを読み取って貰う必要がある。
現場がどういう配置になるか分からない中で、どこにどうマーカーを掲示するかを計画するのは難しい。
パッと用意できそうなところだと三脚を立ててA4サイズの紙を掲示するやり方だが、数十人の観客が同時に位置合わせのためマーカー前で混雑するのはいただけない。
多少工夫すれば複数の位置に分散も可能だが、そもそも配置が読めないと厳しい。
それでいて傾きなど僅かな精度のズレが、離れた位置だと大きくなるので本当に狙った感じにできるか予測できない。

次にImmersalだが、こちらはマップデータ生成のためのスキャンが必要になる。
うまくできさえすれば精度は悪くないのだが、何せスキャンすべきブース周辺も当日設営で、まずそれが完成してからでないと準備ができない。
固定された既存の場所に対してなら有力候補だが、今回のようなワンデーイベントだとやはり難しい。
加えて、実際に観客がごった返した場合にうまく位置合わせできるかも予測が難しい。

DJブース設営の様子

そんなわけで今回は、常にスマホを向けている方向の前方nメートル先に表示する方式にした。
DJブースがあるなら基本的に人は皆そちらを向くだろう、というのと、まぁ必ずしも執筆者の背後に出なくてもいい。
というか、どちらを向いていても文章が出る方が良かろうとの判断。

Playmakerでの処理

STYLYではC#スクリプトを持ち込んだシーンは作れないので、プログラマブルな処理をPlaymakerというビジュアルスクリプティングアセットを使って実装します。

UnityEditor上での基本的な操作に加え、それ以外の処理やSTYLY固有の処理などはSTYLY Plugin for UnityにてCustomActionとして提供されています。

メインシステムのFSMはこんな感じになりました。

MainSystem

メタデータ, メッセージを取得する

前述のCustomActionの中にGetHttpRequestというActionがあります。
その名の通りGETメソッドのHTTP/HTTPSリクエストを行うアクションです。
これを使って、前回解説したFirestoreのREST APIにアクセスします。
繰り返しにはなりますが、メタデータであれば下記のようなJSONが返ってきます。

{
  "name": "projects/xxxxxxx/databases/(default)/documents/texts/metadata",
  "fields": {
    "replayStartIndex": {
      "integerValue": "4"
    },
    "offsetZ": {
      "doubleValue": 6.00001
    },
    "sentencePoolSize": {
      "integerValue": "3"
    },
    "offsetY": {
      "doubleValue": 0.10001
    },
    "currentIndex": {
      "integerValue": "138"
    },
    "charPoolSize": {
      "integerValue": "5"
    },
    "lastUpdated": {
      "timestampValue": "2025-10-25T09:29:54.898Z"
    },
    "requestInterval": {
      "doubleValue": 5.70001
    },
    "sentenceWait": {
      "doubleValue": 5.00001
    },
    "scale": {
      "doubleValue": 0.6501
    },
    "offsetX": {
      "doubleValue": 1e-05
    }
  },
  "createTime": "2025-10-20T18:41:32.679351Z",
  "updateTime": "2025-11-09T06:58:45.538216Z"
}

さて、ここから必要なデータをJSONPathを用いてParseします。
JSONPathについては下記の説明が分かりやすいかと思います。

例えば上記メタデータのうち、現在の最新メッセージのindexを表しているcurrentIndexの値が欲しければ $.fields.currentIndex.integerValue というクエリでアクセスできます。
このクエリで要素を抽出するCustomActionがあるのですが、すいません現状では一般非公開になってます。
いずれ公開するかも知れませんが、それまではこういう処理がやりたい場合は気合でParseするか、別途ネット上にAPIを置いてそこに投げるなど工夫する必要があります。

メッセージのJSONは下記のようなものが返されます。

{
  "name": "projects/xxxxxxx/databases/(default)/documents/texts/messages/items/109",
  "fields": {
    "index": {
      "integerValue": "109"
    },
    "timestamp": {
      "timestampValue": "2025-10-25T09:21:51.724Z"
    },
    "text": {
      "stringValue": "それはちょっとだけ、バイオリンの音に似ている。"
    }
  },
  "createTime": "2025-10-25T09:21:51.824665Z",
  "updateTime": "2025-10-25T09:21:51.824665Z"
}

これを $.fields.text.stringValue というクエリでアクセスすると それはちょっとだけ、バイオリンの音に似ている。 という文字列を取得できます。
一定間隔で index + 1 のメッセージを取得しては表示する、ということの繰り返しを行っています。

文字をフワッと出す

取得した文字列は、SentenceというPrefabのInstanceを生成し、そこに付いているFSMに渡されます。
このFSMでは受け取った文字列を更に1文字ずつ分解して、同様にCharacterというPrefabのInstanceを生成し、そのFSMに渡されます。
大した条件分岐もない流れ作業なのでState名はテキトーなままですね。

Sentence FSM

Character PrefabはWorld SpaceのCanvasを持っていて、そこには TextMeshPro - Text が1つあるだけです。
Character

Character FSMは非常にシンプルで、受け取った文字を上記Textにセットして、それをTweenしています。

Character FSM

具体的には

  • ランダムな範囲でZ軸Rotate
  • ランダムな範囲でSetScale
  • Y+方向にiTweenMoveAdd
  • alphaを0から1に
    しています。
    これを文字のindexでちょっとずつdelayしながら表示すると下記のようになります。

RotateやScaleは本当に僅かな範囲ですが、その小さな揺らぎがあるのとないのでは違うんですよね。
またY+方向の動きはEaseOutCubicだけど、alphaはEaseInSineにしていたりイージングはちょっとだけこだわっています。
これも小さな違いではあるんですがこだわりポイントですね。
各種durationやランダム値の範囲など、イイ感じになるようにパラメータ調整にはわりと時間をかけます。

その調整を何度も高速に行うためにも、効率の良い組み方を心がける必要があります。
調整するActionのパラメータは直接入力せずVariables定義にし、Inputにチェックを入れてInspectorから設定できるようにしたり、Characterの値をSentenceから流し込めるようにして上流からまとめて値を設定できるようにしたり。

最適化:InstancePool

前段ではPrefabのInstanceを生成と書きましたが、実際には毎回その都度CreateObjectをしていません。
あらかじめ十分な数のInstanceを生成しておいて、それを使いまわします。
負荷軽減のための基本的なテクニックですが、実際今回ぐらいの規模でこれをやらなかったら問題になるかというとそうでもないかなとは思います。
ただ経験則でこういうケースではこういう仕組みが必要になるだろうな、という感覚があるのであらかじめ想定しながら処理を組んでいます。

InstancePool

例えばSentenceであれば、SentencePoolの下にPoolInUseがあります。
Sentenceを生成するタイミングでPoolからGetChildして、それをInUseに移動しながら文字列などパラメータを流し込んでActivateしています。
SentenceはCharacterを生成するタイミングで、同じようにCharacterPoolからオブジェクトを持ってきて自分の下にぶら下げます。
そして表示期間が終わったらそれらのCharacterオブジェクトをCharacterPoolに戻し、自分自身もPoolに戻るという動きをしています。

Playmakerではコレクション管理がちょっとやりづらいので、私はHierarchyをコレクション表現としてよく使います。
この手法の利点は、HierarchyViewで状況が把握しやすくデバッグしやすいところです。
複雑な処理を行う際は是非お試しあれ。

PostProcess

仕上げに軽くPostProcessを入れます。
今回はこんな感じでした。

PostProcess

Bloom

文字がうっすらと輝いて光が漏れているような感じにします。
普段だとバキッと光らせたくなっちゃうんですが、心持ちぐらいに抑えめにするのがお上品かと。
TextのFace ColorのIntensityをちょっと持ち上げておきます。

Color Grading

Saturationを下げてモノトーンに近づけつつ、Temperatureを上げたりGammaを赤方向に上げたりしてセピアっぽくしています。
カメラパススルーが表示されるARは、こういうエフェクトをちょっと入れるだけでグッと味が出る印象がありますね。

Vignetta

今まで存在は知っていても、ちゃんと自分の作品の中で使ったことがなかったやつ。
画面の四隅がちょっとだけ暗くなっています。
セピアっぽくするなら併せて入れておきたくなりますね。
これもかかってるんだか、かかってないんだか分からない程度にうっすらお上品に仕上げます。

全くの余談ですが、昔の私は何でもかんでもパラメータを強めに設定しがちでした。
そうしないとエフェクトがかかってるのかどうか、よく分からないからです。
何にでもマヨネーズと一味唐辛子かけちゃう的な?
それが今では「うっすらお上品に仕上げます」とか言っちゃうのとか、我ながら成長したなぁという気持ちで、ちょっと今、嬉しいです。

これらを組み合わせるとこんな感じになります。
う〜ん、イイですねぇエモいですねぇ。

アーカイヴ対応

これは本番イベント後に追加実装した内容ですが、せっかくFirestoreに本番データが入っているので、それをリプレイ再生できるようにしました。
ただ一般公開するにあたり、毎度Firestoreにアクセスすると無料枠を超える可能性があります。
そこでデータをキャッシュする仕組みを作りました。
EditorScriptで、まずFirestoreからメタデータとメッセージを全て取得します。
それを、何も処理を持たないVariable定義だけのFSMにセットして保存します。

Cache

このFSMをScriptableObjectのように扱い、FirestoreのREST APIアクセスの代わりにVariableを読み取って再生することで、外部接続なしにリプレイ再生できるようにしました。
処理を持たないFSMを設定ファイルや、構造体のように扱ったりするのも色々と応用が効くのでオススメです。

アーカイヴは下記のシーンで再生できます。

次回

さて、これで完成したわけだが。
実は本番実施時に深刻な問題が発生していた。
下記の動画をよく見ると、表示される文字が一部になってしまっている。

次回はこの現象の原因と対応について解説する。
乞うご期待。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?