自分だけの写真がゲームで使える! IDOLY PRIDE におけるフォト機能の仕組み
はじめに
株式会社QualiArtsでUnityエンジニアをしている眞鍋と、バックエンドエンジニアをしている筋野です。 2021年6月にリリースされた「IDOLY PRIDE」(以降、アイプラ)のフォト機能開発に携わっています。 本記事ではアイプラにおけるフォト機能について、撮影機能の表現手法と画像の管理手法について紹介します。
フォト機能とは
アイプラにおけるフォト機能とは、ユーザーがゲームのライブシーンや日常シーンを撮影し、自分だけのアイテムとして所持したりキャラに装備できる機能です。
フォト機能にはカメラを使うように自分でゲーム内のキャラを撮影する「撮影モード」と、前日の行動から自動でフォトを作成してくれる「フォトレポート」があります。
また、「撮影モード」にはライブシーンを撮影できる「ライブ撮影」とお仕事や休暇中のシーンを撮影できる「お仕事撮影」の2つのモードがあります。
ライブ撮影
ライブは全てUnityのタイムラインで構成されており、モーションやフェイシャル、カメラワークやライトなどを3Dチームが調整しています。そのため、クライアントでは調整されたタイムラインにキャラクター情報を与えるだけでライブが再生できます。
ズーム機能
ライブ撮影での主な機能としてズーム処理があります。 ライブ撮影でのズームは調整されてない範囲などが映らないようカメラのsensorSizeやlensShiftを変更し、カメラワークでいうドリーアウトなどはおこなわないようにしています。
赤枠がズームなしで見えている範囲です。 赤枠の中で動いている白枠が現在描画されている領域です。
おまかせ撮影機能 (ライブ)
撮影するのに疲れてしまった、普段と違う写真を撮りたい、というユーザー向けにおまかせ撮影機能があります。
ライブのおまかせ撮影はタイムライン上の時間をランダムに選択して撮影を行います。
設計段階ではある程度良いものが映るよう範囲を調整するといった話もあったのですが、何度も利用していると同じ写真しか生まれなくなってしまうのと、ランダムでも良い写真が多く取れることから(3Dチームの調整の賜物だと思ってます)最初と最後を少し間引く以外は完全にランダムで行うようにしました。
お仕事撮影
お仕事撮影は「ラジオ収録」や「雑誌モデル」などのお仕事から、「カフェ」や「温泉」といった休暇中の姿まで、アイドルとより近い位置で自由に撮影できるモードです。
生成情報
キャラの立ち位置や性格、撮影場所などの情報をマスタデータとして持ち、それらに応じたモーションと位置情報を設定しています。
背景はカメラ移動できるように小物も含めてコライダーを設定してい ます。また、背景によってはカメラが写せない領域が定義されており、こちらもコライダーの設定で実現しています。
カメラ操作
初期のカメラ位置からバーチャルパッドによる移動でキャラに近付いたり、スワイプ・2点スワイプでカメラの回転やズーム、上下移動など、ライブと比較して自由度が高い状態で撮影を行えます。
カメラの移動はUnityのCharacterControllerを使用しており、背景のコライダー上を移動します。また、CharacterControllerは登り機能があるため、低い椅子や浮き輪など小物の上に登って撮影することも出来ます。
カメラ位置は現在の仕様だと2箇所あり、それぞれにVirtualCameraが配置されているのですが、カメラ同士のコライダーがぶつからないように背景などとは別のレイヤーを割り当て、LayerCollisionMatrixの設定で調整しています。
おまかせ撮影機能 (お仕事)
お仕事のおまかせ撮影もライブと同じくランダムですが、完全にランダムだと問題が生じるため、立ち位置などは調整しています。
カメラの位置はキャラとカメラの初期座標を中心に一定範囲の床に対してRayを飛ばし、移動候補地を出します。移動候補地の中から見えてはいけない範囲を除外し、残った候補地をランダム選択する事で撮影を行います。
水色の球体位置が移動地点でランダムに変わります。
また、おまかせ撮影では残り枚数を3キャラ均等に写すように調整しているのですが、対象キャラが他のキャラより奥に行ってしまった場合は分かりやすくぼかしを強めるなどの工夫をしています。
フォトレポート
フォトレポートとは、前日のユーザーの行 動に合わせた画像をフォトとして獲得できる機能です。
30万枚を超えるパターンの中から前日の行動に近いフォトがユーザーごとに配られるようになっています。 フォトレポート用のフォトでは使用したキャラクターのみ写るように、いくつかツールを作成して調整しています。
ライブフォト
ユーザーの行動と一致するフォトを撮影する際、複数のキャラクターが写っていたり観客のサイリウムが写っていると、写真の種類が大幅に増えてしまうため、そういったものを含まない範囲で撮影を行えるようにしています。
撮影タイミングは全てタイムラインで制御されているため、指定のタイミングであれば必ず表示されているキャラが決まります。そのため、ツールでは時間の範囲指定で撮影を行えるよう時間をスライドで確認、保存できるようにしています。
お仕事フォト
お仕事フォトでは特に難しいことはしておらず、撮影する際にキャラクターを一人だけ表示するようにしてランダムな地点から撮影を行っています。
撮影画像の管理方法
フォトの画像は「撮影モード」と「フォトレポート」で管理方法が異なります。
「撮影モード」の画像は、FirebaseStorageを利用してユーザー毎のパスへ保存しています。一方「フォトレポート」の画像は、内製のAssetBundle配信基盤であるOctoを利用し、通常のアセットと同様に管理しています。
以下では、「撮影モード」で利用しているFirebaseStorageの設定についてお話しします。Octoについては、過去に執筆されたこちらの記事をご覧頂ければと思います。
複雑化するAssetBundleの配信からロードまでを基盤化した話。
ストレージの構成
フォト画像はユーザー固有の画像であり一度消えてしまうと復元が難しいという問題があります。そのためフォト画像を保存するストレージには、ユーザーが保存や参照を行う通常ストレージと、ライフサイクル管理されているTTLストレージが存在します。
ユーザーがフォトを獲得した場合、対象の画像を通常ストレージに保存します。ユーザーがフォトを削除した場合は、TTLストレージに画像をコピーした後、通常ストレージから元画像を削除します。TTLストレージにコピーされた画像は一定の期間が経過すると自動的に削除されます。
このような設計をする事で、意図せずフォトを削除してしまった場合でも一定期間はフォトを復元できるようになります。
認証設定
FirebaseStorageのセキュリティルールでユーザー情報を利用する場合、FirebaseAuthenticationの request.auth.uid
を利用する事が多いと思いますが、アイプラではサーバーサイドで指定した認証IDをFirebaseAuthenticationに追加してセキュリティルールで利用しています。
以下はFirebaseAdminSDKのGoクライアントを利用した実装例になります。 (実際のゲームで実装されているものとは一部内容が異なります。)
import (
"context"
"log"
firebase "firebase.google.com/go/v4"
)
func SetCustomClaims(ctx context.Context, uid, authID string, app *firebase.App) {
client, err := app.Auth(ctx)
if err != nil {
log.Fatal(err)
}
claims := map[string]interface{}{"authID": authID}
if err = client.SetCustomUserClaims(ctx, uid, claims); err != nil {
log.Fatal(err)
}
}
また、設定されているカスタムクレームはオブジェクトプロパティで取得する事ができます。
import (
"context"
"log"
firebaseauth "firebase.google.com/go/v4/auth"
)
func GetCustomClaims(ctx context.Context, uid string, client *firebaseauth.Client) string {
user, err := client.GetUser(ctx, uid)
if err != nil {
log.Fatal(err)
}
if authID, ok := user.CustomClaims["authID"]; ok {
if str, ok := authID.(string); ok {
return str
}
}
return ""
}
上記により独自に定義した認証IDを使えるようになりますが、ゲームのようにリリースタイミングで大量のユーザーが流入する場合はFirebaseAdminSDKのAPI上限を超えてしまう可能性があり、QUOTA_EXCEEDEDエラーが発生してしまいます。
QUOTA_EXCEEDEDの対策には指数バックオフによるAPIの再実行が推奨されています。また、FirebaseAdminSDKのGoクライアントにはデフォルトで指数バックオフの処理が走るよう設計されております。
hc := internal.WithDefaultRetryConfig(transport)
func WithDefaultRetryConfig(hc *http.Client) *HTTPClient {
twoMinutes := time.Duration(2) * time.Minute
return &HTTPClient{
Client: hc,
RetryConfig: &RetryConfig{
MaxRetries: 4,
CheckForRetry: retryNetworkAndHTTPErrors(
http.StatusInternalServerError,
http.StatusServiceUnavailable,
),
ExpBackoffFactor: 0.5,
MaxDelay: &twoMinutes,
},
}
}
しかし、デフォルトで対象となるエラーは500、503のエラーとなっており、QUOTA_EXCEEDEDのエラーには対応しておりません。
"code": 400,
"message": "QUOTA_EXCEEDED : Exceeded quota for updating account information.",
そのため、アイプラでは独自に指数バックオフの処理を実装しています。
import (
"context"
"errors"
firebaseauth "firebase.google.com/go/v4/auth"
"firebase.google.com/go/v4/errorutils"
"github.com/cenkalti/backoff/v4"
)
func SetCustomClaims(ctx context.Context, uid string, client *firebaseauth.Client, claims map[string]interface{}) error {
b := backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)
if err := backoff.Retry(func() error {
if err := client.SetCustomUserClaims(ctx, uid, claims); err != nil {
if errorutils.IsInvalidArgument(err) {
return errors.New("InternalServerError (Exponential Backoff)")
}
return backoff.Permanent(err)
}
return nil
}, b); err != nil {
return errors.New("InternalServerError")
}
return nil
}
上記実装により、QUOTA_EXCEEDEDが発生した際もリトライ処理が走るようになりました。
しかし、ゲームの初期リリース時などは指数バックオフでも捌き切れないアクセスがあるため、リリース直前には上限緩和申請も別途行なっております。
おわりに
本記事では、アイプラにおけるフォト機能について紹介しました。
Unityではおまかせ撮影でランダムに撮影を行なっており、ライブはタイムラインで作られているため見栄えの良い写真が生まれやすいですが、お仕事撮影では自由度が高い分見栄えがあまりよくないフォトが生まれやすいです。今後のアップデートで、そういったものをより減らせるような対応も検討していきたいです。
FirebaseStorageはセキュリティルールの設定やカスタムクレームの追加を手軽に行えますが、ゲームなどアクセスの多いシステムに組み込む場合は負荷を考慮して一手間加える必要があります。FirebaseStorageだけでなくクラウドインフラを利用する場合は負荷やサービスの上限値を考慮した工夫が必要なため、事前の負荷試験や上限値調査が重要になります。
今回の記事が皆様のゲーム開発に役立てれば幸いです。