電通総研のブログ - TECH PLAY

TECH PLAY

電通総研

電通総研 の技術ブログ

全867件

金融ソリューション事業部 石沢です。ISIDは現在全社で1on1の実施を奨励しており、私自身も部署のメンバーに対して1on1を実施しています。本記事ではISIDにおける1on1の状況と、昨年参加したエール社提供の1on1改善ワークショップ【聴くトレ】に参加した経験と感想をご紹介します。 ISIDにおける1on1の状況 ISIDでは現在1on1を人事施策として奨励しています。実施は強制ではないですが、社として様々な支援を受けることが可能で、実施のためのガイダンスやツール( TeamUp )を希望者は全員利用できるようになっています。ISIDの1on1の取組みは以下サイトでも紹介されていますので、興味があれば参照してください。 わたしと1on1 現在は社の人事施策として推進されている1on1ですが、わたし自身はそれより数年前から書籍やネット記事などを参考に自分のプロジェクトに実験的に適用していました。その時に参考にしていた情報は書籍「 HIGH OUTPUT MANAGEMENT 」(当時は旧版「 インテル 経営の秘密」)など、あとは海外で働く日本人エンジニアの記ネット記事などです。あとは、 見よう見まね ですね。 ISIDでは現場でいろいろ勝手に工夫する文化があるので、わたしと同じようにいろいろな現場で1on1の取組みは進んでいたようです。すぐに社内でも「1on1をやってみたらよかった」「所属部員とのコミュニケーションに1on1を活用するのが良い」という声がすぐに聞こえるようになっていました。社の規定で定められた上司部下の面談はあるのですが、それを拡張してみんな工夫していったという感じです。 そんな中で、2021年には人事部主導で1on1を支援するツールのトライアルや全社展開なども行われ、1on1に関する基本的な ガイドライン なども公開されました。現在では多くの社員が1on1をやっている状況になっています。利用は任意ですが、支援ツールの TeamUp はリマインドや記録機能などが便利で、わたしは愛用しています。わたし自身は隔週の頻度でメンバーとの1on1を日常的に実施しています。 【聴くトレ】参加経緯 日常的に1on1をやってはいるのですが、すこし不安はありました。 自分が1on1を効果的にやれているのかわからない 改善すべき点があるのかどうかもわからない ちょうどそのころ読んでいた「 ヤフーの1on1 」という本で、ヤフーさんでは教育施策として「1on1チェック」というアセスメントや研修をやっているという事が紹介されていました。 うらやま…… 実際のメンバーとの1on1は、内容的にも第 三者 に見てもらったり、話した内容を開示するわけにもいかないし、振返ってみると 練習する場に飢えていたんだ と思います。 素振りがしたいんや…… そう思っていたところに、人事部から エール社さんの 【聴くトレ】 のトライアルをやるので参加者を公募するという話が飛び込んできたので、さっそく飛びついて参加してみたのです。 【聴くトレ】とは 具体的な内容は公式サイトを見ていただくとして、ざっくりこのような内容でした。 キック オフミ ーティングで取組みの説明とコンセプトの話を聞く 自習用動画が配布されるので、【聴くトレ】が重視する注意点、コツを学ぶ サポーターと呼ばれる社外メンターと練習や相談をする 実際に1on1をやる 1on1を評価してもらい、よりよくするためのアド バイス を貰う アド バイス して貰ったことが実務に旨く適用できたかのふりかえりを一緒にやる これは私の問題意識にピッタリでした。 【聴くトレ】の感想 個人的には自分の1on1について振り返る良い機会だったと思います。また社外の方と1on1をすることも新鮮で受講して良かった。ずっと受講し続けたいというわけではありませんが、定期的にこういったサービスで自分のフォームを点検できるのは大変良いと思います。今回はトライアルということでしたが、人事部でも今後継続する方向性で検討しているということで、期待です。 その他の細かい感想はこちら コストパフォーマンスは未確認です(人事部さんありがとう!) サポーターとのマッチング問題はありそうですが、わたしを担当していただいた方は非常に面白かったです。ただ、実際の1on1でも相性の問題はあるはずなので苦手なタイプの人とマッチングしてしまっても、それも修行だと思えば楽しめそう セッションはVoice Onlyのオンライン実施形式(専用のサポートサイト経由での実施となります)。表情が見えないのはちょっと残念でしたが、場所を選ばずに実施できるというメリットもありそう セッションは緊張します(特に初回)。どんなことを言われるのだろうかという不安もありましたが、至らない点もやさしくフィードバックしてもらえて杞憂でしたが…… 普段つかわない筋肉を使ったような疲れはありました(それがやりたかったので問題はないのですが) 詳しくはナイショですが、自分自身の1on1についてはひとつ重要な課題を発見できて良かったです。 客観的に観察してもらえるというのは、やはりよいですね 。うまく解消できているかはわかりませんが、よりそったアド バイス をしていただき、練習させてもらったので改善したと信じたい むすびにかえて 本記事ではISIDの1on1の取組みと、管理職として1on1力をレベルアップしようとしてみたわたしの経験についてご紹介しましたが、いかがでしたか。1on1がうまくいかないというお悩みをお持ちの管理職な方はエールさんのサービスをチェックすると良いと思いますが、むしろそういったサポートも受けられるISIDへの転職もご検討してはいかがでしょうか? 私たちは一緒に働いてくれる仲間を募集しています! ISID 募集職種一覧 執筆: Ishizawa Kento (@kent) 、レビュー: @kou.kinyo ( Shodo で執筆されました )
こんにちは! 電通国際情報サービス 人事部 人材・組織開発グループの川谷です。 今回は、 UdemyBusiness (以下UB)を活用し、 個々に沿った内定者向け教育コンテンツを提供した内容を共有します。 本記事をこんな方におすすめします 就職活動を控えた大学生の方 Udemy,UBの受講を検討されている方 自社でUBを導入済み / 検討中の研修担当者 本記事のあらすじ ISIDでの内定者向け教育コンテンツにおいて、従来の全体一律ト レーニン グ型から「全体のベースアップ+個々人に沿うスキルト レーニン グ」のハイブリッド型に提供をシフトした。 内定者が各自必要とするインプットを得られるようにUBを導入した。そして、入社後の教育にもUBを利用することで、働きながら学ぶ姿勢を支援できる仕組みとした。 Udemy・Udemy Businessとは Udemyは、プログラミングやビジネス、 自己啓発 など様々な分野のコンテンツを提供する、オンライン学習プラットフォームです。各分野の専門家が、合計200,000を超えるコンテンツを提供しています。 UBは、そんなUdemyの講座群から、一般レビューをもとに厳選された講座を受け放題で受講できる法人向けのサービスです。 ISIDにおけるこれまでのUB活用 ISIDでは、2022年上旬にUBを全社導入しました。 公募制で展開しており、23年2月21日現在、約500名程度が受講しています。 コンテンツの質・受講自由度の高さから、受講人数は期を重ねるごとに増加傾向にあり、効果的な受講支援や階層別研修の補助利用など活用の幅を広げております。(UB受講者用Teamsの作成、新人研修の補助としての導入など) これまでのISIDにおける内定者教育について ISIDでは内定者に対して、入社後の早期活躍を支援すべく「心・技・体」の観点で学習機会を提供してきました。 (記載の教育施策は一例です) これまでの教育施策は、主にIT理解度( 基本情報技術者試験 (FE)の取得状況など)に応じて個別に内容を変更していました。しかし、その施策内容の検討/選定が難しく課題となっていました。 そのため、内定者自身が、各自のレベルに合った講座を自由に選べる点が、UB内定者提供に踏み切った理由の一つでした。 そんな内定者教育に、UBをどう活用しているのか? UBのコンテンツ数の多さ、そしてそれらを自由に選んで受講できるメリットを活かし、下記の通り必須受講と任意受講を組み合わせて運用しています。 必須受講 IT理解度が低い人向けに、IT基礎知識を学ぶ講座を人事部で選定し、受講を指示( 基本情報技術者試験 に関するコンテンツなど) 任意受講 一部講座を推薦(ビジネスマナーやセルフマネジメント、論理思考力など) その他、自身が学習したい分野、不足していると思う分野に関する講座 上記の通りISIDではUBを、内定者個々の学習意欲に合わせた教育コンテンツ提供にも活用しています。 どう管理しているの?受講生の実施までチェックしているの? 前章で紹介した推奨コンテンツは、UBの"ラーニングパス機能"を用いて、各内定者の受講状況をチェックできるようにしています。ラーニングパスとは、UB上の講座や社内のコンテンツ、外部リンクなどを組み合わせて作成する"音楽のプレイリスト"のようなものであり、受講状況もチェック可能です。 この機能を活用し、学んでほしい内容を詰め込んだ内定者教育専用のラーニングパスを作成して、展開および受講状況をチェックしています。 人事部としての所感 結論としては、UBは内定者教育にもフィットしている実感があります。 理由は、これまで課題と感じていた「内定者それぞれに合わせた教育コンテンツ提供」が、前述の通りUBが有する豊富なコンテンツ量・受講者の管理が容易なラーニングパスによって対処できていると感じるためです。 内定者によるフィードバックはこれからですが、自身の興味に合うコンテンツをいつでも自由に受講できるUBは、受講者側にも大きなメリットなのではないかと個人的に思っています。 今後の展望 今後の展望としては、以下のようにUBを活用したいと考えています。 (入社後の教育)入社後の教育にもUBを活用し、学び続ける姿勢をサポートしたい。 (今後の内定者研修)今年度と同様に内定者期間にUBを提供。内定者からのフィードバックを受け、ラーニングパスの中身をブラッシュアップする。 おわりに 以上が、弊社の内定者教育にUBを活用した事例の紹介です。 UBを内定者に提供することは、業務の傍ら学び続ける習慣のキッカケになると確信しております。 もちろん内定者教育をeラーニングだけに留めようという思いはありません。ただUBが簡単に活用できるからこそ、より一層狙いを持って、eラーニングもしくは対面型研修を選択するべきだと私個人としては考えています。 本記事が誰かのお役に立てば幸いです。 執筆: @kawatani.tomoro 、レビュー: @nakamura.toshihiro ( Shodo で執筆されました )
こんにちは! 電通国際情報サービス 人事部 人材・組織開発グループの川谷です。 今回は、 UdemyBusiness (以下UB)を活用し、 個々に沿った内定者向け教育コンテンツを提供した内容を共有します。 本記事をこんな方におすすめします 就職活動を控えた大学生の方 Udemy,UBの受講を検討されている方 自社でUBを導入済み / 検討中の研修担当者 本記事のあらすじ ISIDでの内定者向け教育コンテンツにおいて、従来の全体一律ト レーニン グ型から「全体のベースアップ+個々人に沿うスキルト レーニン グ」のハイブリッド型に提供をシフトした。 内定者が各自必要とするインプットを得られるようにUBを導入した。そして、入社後の教育にもUBを利用することで、働きながら学ぶ姿勢を支援できる仕組みとした。 Udemy・Udemy Businessとは Udemyは、プログラミングやビジネス、 自己啓発 など様々な分野のコンテンツを提供する、オンライン学習プラットフォームです。各分野の専門家が、合計200,000を超えるコンテンツを提供しています。 UBは、そんなUdemyの講座群から、一般レビューをもとに厳選された講座を受け放題で受講できる法人向けのサービスです。 ISIDにおけるこれまでのUB活用 ISIDでは、2022年上旬にUBを全社導入しました。 公募制で展開しており、23年2月21日現在、約500名程度が受講しています。 コンテンツの質・受講自由度の高さから、受講人数は期を重ねるごとに増加傾向にあり、効果的な受講支援や階層別研修の補助利用など活用の幅を広げております。(UB受講者用Teamsの作成、新人研修の補助としての導入など) これまでのISIDにおける内定者教育について ISIDでは内定者に対して、入社後の早期活躍を支援すべく「心・技・体」の観点で学習機会を提供してきました。 (記載の教育施策は一例です) これまでの教育施策は、主にIT理解度( 基本情報技術者試験 (FE)の取得状況など)に応じて個別に内容を変更していました。しかし、その施策内容の検討/選定が難しく課題となっていました。 そのため、内定者自身が、各自のレベルに合った講座を自由に選べる点が、UB内定者提供に踏み切った理由の一つでした。 そんな内定者教育に、UBをどう活用しているのか? UBのコンテンツ数の多さ、そしてそれらを自由に選んで受講できるメリットを活かし、下記の通り必須受講と任意受講を組み合わせて運用しています。 必須受講 IT理解度が低い人向けに、IT基礎知識を学ぶ講座を人事部で選定し、受講を指示( 基本情報技術者試験 に関するコンテンツなど) 任意受講 一部講座を推薦(ビジネスマナーやセルフマネジメント、論理思考力など) その他、自身が学習したい分野、不足していると思う分野に関する講座 上記の通りISIDではUBを、内定者個々の学習意欲に合わせた教育コンテンツ提供にも活用しています。 どう管理しているの?受講生の実施までチェックしているの? 前章で紹介した推奨コンテンツは、UBの"ラーニングパス機能"を用いて、各内定者の受講状況をチェックできるようにしています。ラーニングパスとは、UB上の講座や社内のコンテンツ、外部リンクなどを組み合わせて作成する"音楽のプレイリスト"のようなものであり、受講状況もチェック可能です。 この機能を活用し、学んでほしい内容を詰め込んだ内定者教育専用のラーニングパスを作成して、展開および受講状況をチェックしています。 人事部としての所感 結論としては、UBは内定者教育にもフィットしている実感があります。 理由は、これまで課題と感じていた「内定者それぞれに合わせた教育コンテンツ提供」が、前述の通りUBが有する豊富なコンテンツ量・受講者の管理が容易なラーニングパスによって対処できていると感じるためです。 内定者によるフィードバックはこれからですが、自身の興味に合うコンテンツをいつでも自由に受講できるUBは、受講者側にも大きなメリットなのではないかと個人的に思っています。 今後の展望 今後の展望としては、以下のようにUBを活用したいと考えています。 (入社後の教育)入社後の教育にもUBを活用し、学び続ける姿勢をサポートしたい。 (今後の内定者研修)今年度と同様に内定者期間にUBを提供。内定者からのフィードバックを受け、ラーニングパスの中身をブラッシュアップする。 おわりに 以上が、弊社の内定者教育にUBを活用した事例の紹介です。 UBを内定者に提供することは、業務の傍ら学び続ける習慣のキッカケになると確信しております。 もちろん内定者教育をeラーニングだけに留めようという思いはありません。ただUBが簡単に活用できるからこそ、より一層狙いを持って、eラーニングもしくは対面型研修を選択するべきだと私個人としては考えています。 本記事が誰かのお役に立てば幸いです。 執筆: @kawatani.tomoro 、レビュー: @nakamura.toshihiro ( Shodo で執筆されました )
スマートなタイトルが思いつきませんでした。X(クロス) イノベーション 本部 ソフトウェアデザインセンター セキュリティグループの耿です。 インターネットにWebアプリケーションを公開すると、さまざまなHTTPリク エス トを受けることになります。中にはアプリケーションを利用する目的ではない、 情報収集や 脆弱性 を突いて攻撃を狙うリク エス トも多数含まれています。攻撃への根本対策はWebアプリケーションレベルで行うべきですが、対策漏れや ゼロデイ攻撃 に対してはWAFの利用も効果的です。 AWS WAFには AWSマネージドルール が多数用意されており、簡単に導入し多層防御を実現できます。この記事では AWS WAFによるブロックをより厳しくする方法として、WAFのルールがブロックしたリク エス トの IPアドレス を、悪意のあるリク エス トを送信してくる可能性がある IPアドレス とみなして、一定期間 IPアドレス レベルでブロックし続ける仕組みをご紹介します。一度WAFのWeb ACL でブロックに成功しても、攻撃者はパターンを変えて何度も攻撃を試みる場合があり、IPレベルで一定期間ブロックすることで他のWAFルールが対応していないパターンで攻撃が成立するリスクを減らすことを期待しています。 インフラは AWS CDK で実装します。 どんな Web アプリが適しているか アーキテクチャ 1.サブスクリプションフィルターでWAFのログからLambdaを起動 2.DynamoDBのTTLで一定期間後にブロックを解除 3.CDKデプロイ用にSSM Parameter Storeを利用 4.WAF IPセットの楽観ロックで同時更新に対応 サンプルコード 事前準備 (CDK) IPアドレスリストの取得 (CDK) WAFの作成 (CDK) WAFのログを出力 (CDK) DynamoDBテーブルの作成 (CDK) ブロック対象を追加するLambda関数 (CDK) サブスクリプションフィルター (CDK) EventBridgeでIPアドレスリストを同期 (Lambda) パッケージのインポートとSDKクライアントの作成 (Lambda) ハンドラー (Lambda) ログの抽出 (Lambda) IPアドレスをDynamoDBテーブルに追加 (Lambda) DynamoDBからIPアドレスリストを取得 (Lambda) DynamoDBのIPアドレスリストを同期する関数 どんな Web アプリが適しているか 不特定多数の利用者を想定した一般公開のアプリには、これから紹介する仕組みは向かないと思います。WAFの誤検知によってリク エス トをブロックしてしまった場合の影響が大きいからです。社内用アプリなど、利用ユーザーは特定できるがインターネットからアクセスできるアプリがこの仕組みに適していると思います。 アーキテクチャ 1. サブスクリプション フィルターでWAFのログからLambdaを起動 AWS WAFで IPアドレス レンジレベルでリク エス トを許可・ブロックするためには、 IPセット を定義します。WAFのブロック結果からこのIPセットを動的に変更すれば良いので、WAFのブロックログをCloudWatch Logsに出力し、 サブスクリプションフィルター で特定のログをトリガーにLambda関数を起動します。 2.DynamoDBの TTL で一定期間後にブロックを解除 IPアドレス とユーザーの関連は変わり得るので、一定期間経過後は IPアドレス レベルのブロックを自動で解除し、ブロック対象の IPアドレス リストの長さが増え続けないようにします。誤動作で特定の IPアドレス がブロックされてしまった場合の影響を小さくします。 DynamoDBの TTL を利用します。ブロック対象となる IPアドレス をDynamoDBテーブルのレコードとして保持し、 TTL を設定します。EventBridgeで定期的にLambda関数を起動し、DynamoDBテーブルのアイテムのうち、有効期限内の IPアドレス のみでWAFのIPセットを更新します。 3.CDKデプロイ用にSSM Parameter Storeを利用 今回はCDKでインフラを構築する前提なので、IPセットもCDKコードに記載することになります。ただしIPセットの中身は、CDKデプロイ時に先祖返りしないように SSM Parameter Storeパラメータから取得 するようにします。Lambda関数でDynamoDBテーブルから有効な IPアドレス を取得した後は、このパラメータも更新するようにします。 最終的な アーキテクチャ は次の図の通りになります。ブロック対象の IPアドレス 情報はWAFのIPセット、DynamoDBテーブル、Parameter Storeパラメータと3箇所に分散していますが、DynamoDBテーブルのデータを正とし、IPセットとParameter StoreパラメータのデータはDynamoDBテーブルから結果整合的に更新されるようになっています。 4.WAF IPセットの楽観ロックで同時更新に対応 同じIPセットを更新する箇所が複数あり、また複数のWAFブロックログが同時に出力された場合はブロック対象 IPアドレス の追加が同時に実行される可能性があります。この複数の更新に対する 排他制御 の仕組みが、IPセットの API にあります。 WAF IPセットを取得する GetIPSet API のレスポンスで LockToken を取得し、IPセットを更新する UpdateIPSet API を呼びだす時のリク エス トに付加することで、楽観ロックを容易に実現できます。 WAF IPセットを更新するUpdateIPSet API は、個別の IPアドレス レンジを追加、削除するのではなく、更新時に IPアドレス レンジの全体を置き換えるような API 仕様になっているため、このような楽観ロックの仕組みが用意されているのだと思います。 サンプルコード 以上で説明した仕組みを実現するためのサンプルコードをご紹介します。 事前準備 ブロック対象の IPアドレス リストを保持するParameter Storeパラメータを手動で作成しておきます。ここでは MALICIOUS_IP_LIST というパラメータ名とします。空文字では登録できないので初期値は半角スペース 1 つとし、Lambda関数で動的に更新していきます。 1.1.1.1/32;2.2.2.2/32 のように、複数のIP CIDRを セミ コロンで区切るフォーマットを想定します。 (CDK) IPアドレス リストの取得 Parameter Storeパラメータより IPアドレス リストを取得し、文字列型の配列に変換します。それを利用してWAFのIPセットを定義します。 const ipListParamName = "MALICIOUS_IP_LIST" ; const maliciousIpSet = ssm.StringParameter.valueFromLookup ( this , ipListParamName ) .split ( ";" ) .map (( ip ) => ip.trim ()) .filter (( ip ) => ip ); const wafIpSetName = "MaliciousIpSet" ; const wafIpSet = new wafv2.CfnIPSet ( this , "MaliciousIpSet" , { name: wafIpSetName , ipAddressVersion: "IPV4" , scope: "REGIONAL" , // CloudFront用のWAFの場合は "CLOUDFRONT" addresses: maliciousIpSet , } ); またCDKデプロイする際には、 コンテキスト をリセットして常にParameter Storeから最新のパラメータを取得するように、 cdk deploy の前に cdk context --clear を実行するようにします。 cdk context --clear cdk deploy MyStack --require-approval never (CDK) WAFの作成 WAFを作成します。最初に評価されるルールに IpConstraintStatement を作成し、先ほど作成した IPセットをブロックします。他のルールは AWSManagedRulesCommonRuleSet など、自由に追加します。 WAFのALBやCloudFrontなどへの関連付けは省略します。 const wafWebAcl = new wafv2.CfnWebACL ( this , "WafV2WebAcl" , { defaultAction: { allow: {} } , scope: "REGIONAL" , // CloudFront用のWAFの場合は "CLOUDFRONT" visibilityConfig: { cloudWatchMetricsEnabled: true , sampledRequestsEnabled: true , metricName: "WafV2WebAcl" , } , rules: [ { name: "IpConstraintStatement" , priority: 10 , statement: { ipSetReferenceStatement: { arn: wafIpSet.attrArn , } , } , action: { block: {} } , visibilityConfig: { cloudWatchMetricsEnabled: true , sampledRequestsEnabled: true , metricName: "IpConstraintStatement" , } , } , { name: "AWSManagedRulesCommonRuleSet" , priority: 20 , statement: { managedRuleGroupStatement: { vendorName: "AWS" , name: "AWSManagedRulesCommonRuleSet" , } , } , overrideAction: { none: {} } , visibilityConfig: { cloudWatchMetricsEnabled: true , sampledRequestsEnabled: true , metricName: "AWSManagedRulesCommonRuleSet" , } , } , // 他のルールは省略 ] , } ); (CDK) WAFのログを出力 WAFのBLOCKログとCOUNTログをCloudWatch Logsに出力します。 const wafLogGroup = new logs.LogGroup ( this , "WafLogGroup" , { logGroupName: "waf-logs" , } ); const logConfig = new wafv2.CfnLoggingConfiguration ( this , "WafV2LoggingConfiguration" , { logDestinationConfigs: [ `arn:aws:logs: ${ region } : ${ accountId } :log-group: ${ wafLogGroup.logGroupName } ` ] , resourceArn: wafWebAcl.attrArn , loggingFilter: { DefaultBehavior: "DROP" , Filters: [ { Behavior: "KEEP" , Conditions: [{ ActionCondition: { Action: "BLOCK" } } , { ActionCondition: { Action: "COUNT" } }] , Requirement: "MEETS_ANY" , } , ] , } , } ); logConfig.addDependsOn ( wafWebAcl ); (CDK) DynamoDBテーブルの作成 ブロック対象 IPアドレス 用のDynamoDBテーブルを作成します。 パーティション キーはIP CIDRで、 ip_range というキー名にします。 TTL は expires という名前の属性にしています。 const ipTable = new dynamodb.Table ( this , "MaliciousIpTable" , { tableName: "malicious-ip" , partitionKey: { name: "ip_range" , type : dynamodb.AttributeType.STRING } , billingMode: dynamodb.BillingMode.PAY_PER_REQUEST , timeToLiveAttribute: "expires" , } ); (CDK) ブロック対象を追加するLambda関数 ブロック対象の IPアドレス を追加するLambda関数を作成します。関数内で利用するための 環境変数 をいくつか設定し、必要な API コールをするための権限を渡します。 const autoBlockMaliciousIpFunction = new lambdaNodejs.NodejsFunction ( this , "AutoBlockMaliciousIpFunction" , { entry: "functions/auto-block-malicious-ip.ts" , runtime: Runtime.NODEJS_18_X , timeout: Duration.minutes ( 3 ), environment: { TABLE_NAME: ipTable.tableName , // DynamoDBテーブル名 IP_LIST_PARAMETER_NAME: ipListParamName , // Parameter Storeパラメータ名 IP_SET_NAME: wafIpSetName , // IPセット名 IP_SET_ID: wafIpSet.attrId , // IPセットID IP_SET_SCOPE: "REGIONAL" , // IPセットスコープ。CloudFront用のWAFの場合は "CLOUDFRONT" } , } ); // DynamoDBテーブルの参照・更新権限 ipTable.grantReadWriteData ( autoBlockMaliciousIpFunction ); // IPセットの参照・更新権限 autoBlockMaliciousIpFunction.addToRolePolicy ( new iam.PolicyStatement ( { resources: [ wafIpSet.attrArn ] , actions: [ "wafv2:GetIpSet" , "wafv2:UpdateIPSet" ] , effect: iam.Effect.ALLOW , } ) ); // パラメータの更新権限 autoBlockMaliciousIpFunction.addToRolePolicy ( new iam.PolicyStatement ( { resources: [ `arn:aws:ssm: ${ region } : ${ accountId } :parameter/ ${ ipListParamName } ` ] , actions: [ "ssm:PutParameter" ] , effect: iam.Effect.ALLOW , } ) ); (CDK) サブスクリプション フィルター ブロックログに対して サブスクリプション フィルターでLambda関数を起動させます。ただし IpConstraintStatement ルール自体でブロックされた場合はLambda関数を起動しないようにします。他にも地理的一致条件や、他の IPアドレス 条件でブロックされた場合、対象の IPアドレス はどちらにせよブロックされるので、Lambda関数の起動条件から除外しても良いでしょう。さらに会社の IPアドレス の場合はブロック対象外とするなど、フィルターパターンは要件に応じていろいろとカスタマイズできるでしょう。 const filter = wafLogGroup.addSubscriptionFilter ( "WafLogFilter" , { destination: new destinations.LambdaDestination ( autoBlockMaliciousIpFunction ), filterPattern: logs.FilterPattern.literal ( '{ $.action = "BLOCK" && $.terminatingRuleId != "IpConstraintStatement" }' ), } ); // https://github.com/aws/aws-cdk/issues/23177 へのワークアラウンド // これがないと初回デプロイで失敗する ( filter.node.defaultChild as logs.CfnSubscriptionFilter ) .addDependency ( filter.node.findChild ( "CanInvokeLambda" ) as CfnPermission ); (CDK) EventBridgeで IPアドレス リストを同期 定期的にEventBridgeで起動し、DynamoDBの IPアドレス リストをParameter StoreパラメータとIPセットに同期するLambda関数を作成します。EventBridgeルールは、ここでは1時間に1度起動するようにしています。 const autoUpdateMaliciousIpFunction = new lambdaNodejs.NodejsFunction ( this , "AutoUpdateMaliciousIpFunction" , { entry: "functions/auto-update-malicious-ip.ts" , runtime: Runtime.NODEJS_18_X , timeout: Duration.minutes ( 1 ), environment: { TABLE_NAME: ipTable.tableName , IP_LIST_PARAMETER_NAME: ipListParamName , IP_SET_NAME: wafIpSetName , IP_SET_ID: wafIpSet.attrId , IP_SET_SCOPE: "REGIONAL" , } , } ); // DynamoDBテーブルの参照権限のみ ipTable.grantReadData ( autoUpdateMaliciousIpFunction ); autoUpdateMaliciousIpFunction.addToRolePolicy ( new iam.PolicyStatement ( { resources: [ wafIpSet.attrArn ] , actions: [ "wafv2:GetIpSet" , "wafv2:UpdateIPSet" ] , effect: iam.Effect.ALLOW , } ) ); autoUpdateMaliciousIpFunction.addToRolePolicy ( new iam.PolicyStatement ( { resources: [ `arn:aws:ssm: ${ region } : ${ accountId } :parameter/ ${ ipListParamName } ` ] , actions: [ "ssm:PutParameter" ] , effect: iam.Effect.ALLOW , } ) ); new events.Rule ( this , "AutoUpdateWafIpListEventRule" , { schedule: events.Schedule.cron ( { minute: "0" } ), targets: [ new eventTargets.LambdaFunction ( autoUpdateMaliciousIpFunction ) ] , } ); (Lambda) パッケージのインポートと SDK クライアントの作成 ここからはブロック対象の IPアドレス を追加するLambda関数のコードを紹介します。まず必要なパッケージのインポートと、使用する各サービスの SDK クライアントを作成する部分です。 // functions/auto-block-malicious-ip.ts import * as zlib from "zlib"; import { DynamoDBClient, ScanCommand, ScanCommandInput, AttributeValue, PutItemCommand, } from "@aws-sdk/client-dynamodb"; import { SSMClient, PutParameterCommand } from "@aws-sdk/client-ssm"; import { WAFV2Client, UpdateIPSetCommand, GetIPSetCommand } from "@aws-sdk/client-wafv2"; import { Handler, CloudWatchLogsEvent, CloudWatchLogsLogEvent, CloudWatchLogsDecodedData } from "aws-lambda"; const dynamodbClient = new DynamoDBClient({ region: process.env.AWS_REGION }); const ssmClient = new SSMClient({ region: process.env.AWS_REGION }); const wafClient = new WAFV2Client({ region: process.env.AWS_REGION }); (Lambda) ハンドラー サブスクリプション フィルターを受けて起動する関数なので、引数のイベントは CloudWatchLogsEvent 型になります。全体の処理の流れはこの通りです。 1. ログイベントを抽出 2. IPアドレスをDynamoDBテーブルに追加 3. WAF IPセットのロックトークンを取得(楽観ロック開始) 4. DynamoDBからIPアドレスリストを取得 5. Parameter Storeパラメータを更新 6. WAF IPセットを更新。失敗したら3からやり直す export const handler: Handler = async ( input: CloudWatchLogsEvent ) => { if ( ! process .env.IP_SET_ID || ! process .env.IP_SET_NAME || ! process .env.TABLE_NAME || ! process .env.IP_LIST_PARAMETER_NAME || ! process .env.IP_SET_SCOPE || ! [ "REGIONAL" , "CLOUDFRONT" ] .includes ( process .env.IP_SET_SCOPE ) ) { return; } // 1. ログイベントを抽出(関数の中身は後述) const logEvents = await extractAwsLogEvents ( input ); // 各イベントに対してループ処理 for ( const event of logEvents ) { type LogMessageType = { httpRequest?: { clientIp?: string ; } ; } ; const message = JSON .parse ( event.message ) as LogMessageType ; if ( ! message.httpRequest || ! message.httpRequest.clientIp ) continue; // 2. IPアドレスをDynamoDBテーブルに追加(関数の中身は後述) await addDynamodbItem ( { tableName: process .env.TABLE_NAME , ipAddress: message.httpRequest.clientIp } ); let retryCount = 10 ; while ( retryCount > 0 ) { // 3. WAF IPセットのロックトークンを取得(楽観ロック開始) const { LockToken: lockToken } = await wafClient.send ( new GetIPSetCommand ( { Name: process .env.IP_SET_NAME , Scope: process .env.IP_SET_SCOPE , Id: process .env.IP_SET_ID , } ) ); // 4. DynamoDBからIPアドレスリストを取得(関数の中身は後述) const ipRanges: string [] = await getAllIpsFromDynamodb ( { tableName: process .env.TABLE_NAME } ); const ipRangeParam = ipRanges.length > 0 ? ipRanges.join ( ";" ) : " " ; // 5. Parameter Storeパラメータを更新 await ssmClient.send ( new PutParameterCommand ( { Name: process .env.IP_LIST_PARAMETER_NAME , Value: ipRangeParam , Overwrite: true } ) ); try { // 6. WAF IPセットを更新 // lockToken発行後に更新されているとエラーがThrowされる await wafClient.send ( new UpdateIPSetCommand ( { Name: process .env.IP_SET_NAME , Id: process .env.IP_SET_ID , Scope: process .env.IP_SET_SCOPE , Addresses: ipRanges , LockToken: lockToken , } ) ); retryCount = 0 ; } catch { // 楽観ロックで失敗したら一定回数リトライ retryCount -= 1 ; } } } } ; (Lambda) ログの抽出 ハンドラーの引数からログイベントの中身を抽出する関数は こちら を参考にして実装します。 const extractAwsLogEvents = async ( input: CloudWatchLogsEvent ) : Promise < CloudWatchLogsLogEvent [] > => { const payload = Buffer . from( input.awslogs.data , "base64" ); const result = await new Promise < string >(( resolve , reject ) => { zlib.gunzip ( payload , ( e , result ) => { return e ? reject ( e ) : resolve ( result.toString ( "ascii" )); } ); } ); return ( JSON .parse ( result ) as CloudWatchLogsDecodedData ) .logEvents ; } ; (Lambda) IPアドレス をDynamoDBテーブルに追加 ログに記録された IPアドレス をDynamoDBテーブルにアイテムとして追加する部分は次のようになります。 TTL のフォーマットは Unix エポック時間形式の秒単位 であり、現在時刻より1時間後としています。 const addDynamodbItem = async ( props: { tableName: string ; ipAddress: string } ) : Promise < void > => { // 1時間ブロックする const expires = Math .floor (new Date () .getTime () / 1000 ) + 3600 ; const command = new PutItemCommand ( { TableName: props.tableName , Item: { ip_range: { S: ` ${ props.ipAddress } /32` } , expires: { N: ` ${ expires } ` } , } , } ); await dynamodbClient.send ( command ); } ; (Lambda) DynamoDBから IPアドレス リストを取得 Parameter StoreパラメータとIPセットを更新するために、DynamoDBテーブルをスキャンし全 IPアドレス を取得する部分です。DynamoDBの TTL で有効期限が切れてもすぐにアイテムが削除される保証はなく、最大で48時間テーブルに残るため、スキャン後に フィルター で TTL が有効なアイテムをフィルタリングします。また読み取りは 強力な整合性 とし、直前で追加した IPアドレス が確実に結果に含まれるようにします。(結果整合性よりも多くのキャパシティユニットを消費することにご留意ください) const getAllIpsFromDynamodb = async ( props: { tableName: string } ) : Promise < string [] > => { let startKey: Record < string , AttributeValue > | undefined = undefined ; let shouldScanNext = true ; const ipRanges: string [] = [] ; const currentTime = Math .floor (new Date () .getTime () / 1000 ); while ( shouldScanNext ) { const params: ScanCommandInput = { TableName: props.tableName , ConsistentRead: true , ExpressionAttributeNames: { "#e" : "expires" } , ExpressionAttributeValues: { ":1" : { N: ` ${ currentTime } ` } } , FilterExpression: "#e >= :1" , ExclusiveStartKey: startKey , } ; const { Items , LastEvaluatedKey } = await dynamodbClient.send (new ScanCommand ( params )); Items?.forEach (( item ) => { if ( item [ "ip_range" ] .S ) ipRanges.push ( item [ "ip_range" ] .S ); } ); if ( LastEvaluatedKey ) { startKey = LastEvaluatedKey ; } else { shouldScanNext = false ; } } return ipRanges ; } ; これで、ブロック対象の IPアドレス をIPセットに追加するLambda関数を作成できました。 (Lambda) DynamoDBの IPアドレス リストを同期する関数 EventBridgeを受けて定期的に起動し、DynamoDBの IPアドレス リストをParameter StoreパラメータとIPセットに同期するLambda関数は、先ほどのLambda関数の 3 ~ 6 の処理を利用すれば作成できます。 3. WAF IPセットのロックトークンを取得(楽観ロック開始) 4. DynamoDBからIPアドレスリストを取得 5. Parameter Storeパラメータを更新 6. WAF IPセットを更新。失敗したら3からやり直す 以上で、 AWS WAFでブロックしたリク エス トの IPアドレス を、一定期間 IPアドレス レベルでブロックし続ける仕組みを実現できました。人手を介さずに完全に自動で動き、ブロック対象の IPアドレス は一定期間後に自動消去されるので増え続けることもなく、CDKのデプロイに対してデータが上書きされることもありません。 お読みいただいてありがとうございました! 私たちは同じチームで働いてくれる仲間を大募集しています!たくさんのご応募をお待ちしています。 セキュリティエンジニア(セキュリティ設計) 執筆: @kou.kinyo 、レビュー: 寺山 輝 (@terayama.akira) ( Shodo で執筆されました )
スマートなタイトルが思いつきませんでした。X(クロス) イノベーション 本部 ソフトウェアデザインセンター セキュリティグループの耿です。 インターネットにWebアプリケーションを公開すると、さまざまなHTTPリク エス トを受けることになります。中にはアプリケーションを利用する目的ではない、 情報収集や 脆弱性 を突いて攻撃を狙うリク エス トも多数含まれています。攻撃への根本対策はWebアプリケーションレベルで行うべきですが、対策漏れや ゼロデイ攻撃 に対してはWAFの利用も効果的です。 AWS WAFには AWSマネージドルール が多数用意されており、簡単に導入し多層防御を実現できます。この記事では AWS WAFによるブロックをより厳しくする方法として、WAFのルールがブロックしたリク エス トの IPアドレス を、悪意のあるリク エス トを送信してくる可能性がある IPアドレス とみなして、一定期間 IPアドレス レベルでブロックし続ける仕組みをご紹介します。一度WAFのWeb ACL でブロックに成功しても、攻撃者はパターンを変えて何度も攻撃を試みる場合があり、IPレベルで一定期間ブロックすることで他のWAFルールが対応していないパターンで攻撃が成立するリスクを減らすことを期待しています。 インフラは AWS CDK で実装します。 どんな Web アプリが適しているか アーキテクチャ 1.サブスクリプションフィルターでWAFのログからLambdaを起動 2.DynamoDBのTTLで一定期間後にブロックを解除 3.CDKデプロイ用にSSM Parameter Storeを利用 4.WAF IPセットの楽観ロックで同時更新に対応 サンプルコード 事前準備 (CDK) IPアドレスリストの取得 (CDK) WAFの作成 (CDK) WAFのログを出力 (CDK) DynamoDBテーブルの作成 (CDK) ブロック対象を追加するLambda関数 (CDK) サブスクリプションフィルター (CDK) EventBridgeでIPアドレスリストを同期 (Lambda) パッケージのインポートとSDKクライアントの作成 (Lambda) ハンドラー (Lambda) ログの抽出 (Lambda) IPアドレスをDynamoDBテーブルに追加 (Lambda) DynamoDBからIPアドレスリストを取得 (Lambda) DynamoDBのIPアドレスリストを同期する関数 どんな Web アプリが適しているか 不特定多数の利用者を想定した一般公開のアプリには、これから紹介する仕組みは向かないと思います。WAFの誤検知によってリク エス トをブロックしてしまった場合の影響が大きいからです。社内用アプリなど、利用ユーザーは特定できるがインターネットからアクセスできるアプリがこの仕組みに適していると思います。 アーキテクチャ 1. サブスクリプション フィルターでWAFのログからLambdaを起動 AWS WAFで IPアドレス レンジレベルでリク エス トを許可・ブロックするためには、 IPセット を定義します。WAFのブロック結果からこのIPセットを動的に変更すれば良いので、WAFのブロックログをCloudWatch Logsに出力し、 サブスクリプションフィルター で特定のログをトリガーにLambda関数を起動します。 2.DynamoDBの TTL で一定期間後にブロックを解除 IPアドレス とユーザーの関連は変わり得るので、一定期間経過後は IPアドレス レベルのブロックを自動で解除し、ブロック対象の IPアドレス リストの長さが増え続けないようにします。誤動作で特定の IPアドレス がブロックされてしまった場合の影響を小さくします。 DynamoDBの TTL を利用します。ブロック対象となる IPアドレス をDynamoDBテーブルのレコードとして保持し、 TTL を設定します。EventBridgeで定期的にLambda関数を起動し、DynamoDBテーブルのアイテムのうち、有効期限内の IPアドレス のみでWAFのIPセットを更新します。 3.CDKデプロイ用にSSM Parameter Storeを利用 今回はCDKでインフラを構築する前提なので、IPセットもCDKコードに記載することになります。ただしIPセットの中身は、CDKデプロイ時に先祖返りしないように SSM Parameter Storeパラメータから取得 するようにします。Lambda関数でDynamoDBテーブルから有効な IPアドレス を取得した後は、このパラメータも更新するようにします。 最終的な アーキテクチャ は次の図の通りになります。ブロック対象の IPアドレス 情報はWAFのIPセット、DynamoDBテーブル、Parameter Storeパラメータと3箇所に分散していますが、DynamoDBテーブルのデータを正とし、IPセットとParameter StoreパラメータのデータはDynamoDBテーブルから結果整合的に更新されるようになっています。 4.WAF IPセットの楽観ロックで同時更新に対応 同じIPセットを更新する箇所が複数あり、また複数のWAFブロックログが同時に出力された場合はブロック対象 IPアドレス の追加が同時に実行される可能性があります。この複数の更新に対する 排他制御 の仕組みが、IPセットの API にあります。 WAF IPセットを取得する GetIPSet API のレスポンスで LockToken を取得し、IPセットを更新する UpdateIPSet API を呼びだす時のリク エス トに付加することで、楽観ロックを容易に実現できます。 WAF IPセットを更新するUpdateIPSet API は、個別の IPアドレス レンジを追加、削除するのではなく、更新時に IPアドレス レンジの全体を置き換えるような API 仕様になっているため、このような楽観ロックの仕組みが用意されているのだと思います。 サンプルコード 以上で説明した仕組みを実現するためのサンプルコードをご紹介します。 事前準備 ブロック対象の IPアドレス リストを保持するParameter Storeパラメータを手動で作成しておきます。ここでは MALICIOUS_IP_LIST というパラメータ名とします。空文字では登録できないので初期値は半角スペース 1 つとし、Lambda関数で動的に更新していきます。 1.1.1.1/32;2.2.2.2/32 のように、複数のIP CIDRを セミ コロンで区切るフォーマットを想定します。 (CDK) IPアドレス リストの取得 Parameter Storeパラメータより IPアドレス リストを取得し、文字列型の配列に変換します。それを利用してWAFのIPセットを定義します。 const ipListParamName = "MALICIOUS_IP_LIST" ; const maliciousIpSet = ssm.StringParameter.valueFromLookup ( this , ipListParamName ) .split ( ";" ) .map (( ip ) => ip.trim ()) .filter (( ip ) => ip ); const wafIpSetName = "MaliciousIpSet" ; const wafIpSet = new wafv2.CfnIPSet ( this , "MaliciousIpSet" , { name: wafIpSetName , ipAddressVersion: "IPV4" , scope: "REGIONAL" , // CloudFront用のWAFの場合は "CLOUDFRONT" addresses: maliciousIpSet , } ); またCDKデプロイする際には、 コンテキスト をリセットして常にParameter Storeから最新のパラメータを取得するように、 cdk deploy の前に cdk context --clear を実行するようにします。 cdk context --clear cdk deploy MyStack --require-approval never (CDK) WAFの作成 WAFを作成します。最初に評価されるルールに IpConstraintStatement を作成し、先ほど作成した IPセットをブロックします。他のルールは AWSManagedRulesCommonRuleSet など、自由に追加します。 WAFのALBやCloudFrontなどへの関連付けは省略します。 const wafWebAcl = new wafv2.CfnWebACL ( this , "WafV2WebAcl" , { defaultAction: { allow: {} } , scope: "REGIONAL" , // CloudFront用のWAFの場合は "CLOUDFRONT" visibilityConfig: { cloudWatchMetricsEnabled: true , sampledRequestsEnabled: true , metricName: "WafV2WebAcl" , } , rules: [ { name: "IpConstraintStatement" , priority: 10 , statement: { ipSetReferenceStatement: { arn: wafIpSet.attrArn , } , } , action: { block: {} } , visibilityConfig: { cloudWatchMetricsEnabled: true , sampledRequestsEnabled: true , metricName: "IpConstraintStatement" , } , } , { name: "AWSManagedRulesCommonRuleSet" , priority: 20 , statement: { managedRuleGroupStatement: { vendorName: "AWS" , name: "AWSManagedRulesCommonRuleSet" , } , } , overrideAction: { none: {} } , visibilityConfig: { cloudWatchMetricsEnabled: true , sampledRequestsEnabled: true , metricName: "AWSManagedRulesCommonRuleSet" , } , } , // 他のルールは省略 ] , } ); (CDK) WAFのログを出力 WAFのBLOCKログとCOUNTログをCloudWatch Logsに出力します。 const wafLogGroup = new logs.LogGroup ( this , "WafLogGroup" , { logGroupName: "waf-logs" , } ); const logConfig = new wafv2.CfnLoggingConfiguration ( this , "WafV2LoggingConfiguration" , { logDestinationConfigs: [ `arn:aws:logs: ${ region } : ${ accountId } :log-group: ${ wafLogGroup.logGroupName } ` ] , resourceArn: wafWebAcl.attrArn , loggingFilter: { DefaultBehavior: "DROP" , Filters: [ { Behavior: "KEEP" , Conditions: [{ ActionCondition: { Action: "BLOCK" } } , { ActionCondition: { Action: "COUNT" } }] , Requirement: "MEETS_ANY" , } , ] , } , } ); logConfig.addDependsOn ( wafWebAcl ); (CDK) DynamoDBテーブルの作成 ブロック対象 IPアドレス 用のDynamoDBテーブルを作成します。 パーティション キーはIP CIDRで、 ip_range というキー名にします。 TTL は expires という名前の属性にしています。 const ipTable = new dynamodb.Table ( this , "MaliciousIpTable" , { tableName: "malicious-ip" , partitionKey: { name: "ip_range" , type : dynamodb.AttributeType.STRING } , billingMode: dynamodb.BillingMode.PAY_PER_REQUEST , timeToLiveAttribute: "expires" , } ); (CDK) ブロック対象を追加するLambda関数 ブロック対象の IPアドレス を追加するLambda関数を作成します。関数内で利用するための 環境変数 をいくつか設定し、必要な API コールをするための権限を渡します。 const autoBlockMaliciousIpFunction = new lambdaNodejs.NodejsFunction ( this , "AutoBlockMaliciousIpFunction" , { entry: "functions/auto-block-malicious-ip.ts" , runtime: Runtime.NODEJS_18_X , timeout: Duration.minutes ( 3 ), environment: { TABLE_NAME: ipTable.tableName , // DynamoDBテーブル名 IP_LIST_PARAMETER_NAME: ipListParamName , // Parameter Storeパラメータ名 IP_SET_NAME: wafIpSetName , // IPセット名 IP_SET_ID: wafIpSet.attrId , // IPセットID IP_SET_SCOPE: "REGIONAL" , // IPセットスコープ。CloudFront用のWAFの場合は "CLOUDFRONT" } , } ); // DynamoDBテーブルの参照・更新権限 ipTable.grantReadWriteData ( autoBlockMaliciousIpFunction ); // IPセットの参照・更新権限 autoBlockMaliciousIpFunction.addToRolePolicy ( new iam.PolicyStatement ( { resources: [ wafIpSet.attrArn ] , actions: [ "wafv2:GetIpSet" , "wafv2:UpdateIPSet" ] , effect: iam.Effect.ALLOW , } ) ); // パラメータの更新権限 autoBlockMaliciousIpFunction.addToRolePolicy ( new iam.PolicyStatement ( { resources: [ `arn:aws:ssm: ${ region } : ${ accountId } :parameter/ ${ ipListParamName } ` ] , actions: [ "ssm:PutParameter" ] , effect: iam.Effect.ALLOW , } ) ); (CDK) サブスクリプション フィルター ブロックログに対して サブスクリプション フィルターでLambda関数を起動させます。ただし IpConstraintStatement ルール自体でブロックされた場合はLambda関数を起動しないようにします。他にも地理的一致条件や、他の IPアドレス 条件でブロックされた場合、対象の IPアドレス はどちらにせよブロックされるので、Lambda関数の起動条件から除外しても良いでしょう。さらに会社の IPアドレス の場合はブロック対象外とするなど、フィルターパターンは要件に応じていろいろとカスタマイズできるでしょう。 const filter = wafLogGroup.addSubscriptionFilter ( "WafLogFilter" , { destination: new destinations.LambdaDestination ( autoBlockMaliciousIpFunction ), filterPattern: logs.FilterPattern.literal ( '{ $.action = "BLOCK" && $.terminatingRuleId != "IpConstraintStatement" }' ), } ); // https://github.com/aws/aws-cdk/issues/23177 へのワークアラウンド // これがないと初回デプロイで失敗する ( filter.node.defaultChild as logs.CfnSubscriptionFilter ) .addDependency ( filter.node.findChild ( "CanInvokeLambda" ) as CfnPermission ); (CDK) EventBridgeで IPアドレス リストを同期 定期的にEventBridgeで起動し、DynamoDBの IPアドレス リストをParameter StoreパラメータとIPセットに同期するLambda関数を作成します。EventBridgeルールは、ここでは1時間に1度起動するようにしています。 const autoUpdateMaliciousIpFunction = new lambdaNodejs.NodejsFunction ( this , "AutoUpdateMaliciousIpFunction" , { entry: "functions/auto-update-malicious-ip.ts" , runtime: Runtime.NODEJS_18_X , timeout: Duration.minutes ( 1 ), environment: { TABLE_NAME: ipTable.tableName , IP_LIST_PARAMETER_NAME: ipListParamName , IP_SET_NAME: wafIpSetName , IP_SET_ID: wafIpSet.attrId , IP_SET_SCOPE: "REGIONAL" , } , } ); // DynamoDBテーブルの参照権限のみ ipTable.grantReadData ( autoUpdateMaliciousIpFunction ); autoUpdateMaliciousIpFunction.addToRolePolicy ( new iam.PolicyStatement ( { resources: [ wafIpSet.attrArn ] , actions: [ "wafv2:GetIpSet" , "wafv2:UpdateIPSet" ] , effect: iam.Effect.ALLOW , } ) ); autoUpdateMaliciousIpFunction.addToRolePolicy ( new iam.PolicyStatement ( { resources: [ `arn:aws:ssm: ${ region } : ${ accountId } :parameter/ ${ ipListParamName } ` ] , actions: [ "ssm:PutParameter" ] , effect: iam.Effect.ALLOW , } ) ); new events.Rule ( this , "AutoUpdateWafIpListEventRule" , { schedule: events.Schedule.cron ( { minute: "0" } ), targets: [ new eventTargets.LambdaFunction ( autoUpdateMaliciousIpFunction ) ] , } ); (Lambda) パッケージのインポートと SDK クライアントの作成 ここからはブロック対象の IPアドレス を追加するLambda関数のコードを紹介します。まず必要なパッケージのインポートと、使用する各サービスの SDK クライアントを作成する部分です。 // functions/auto-block-malicious-ip.ts import * as zlib from "zlib"; import { DynamoDBClient, ScanCommand, ScanCommandInput, AttributeValue, PutItemCommand, } from "@aws-sdk/client-dynamodb"; import { SSMClient, PutParameterCommand } from "@aws-sdk/client-ssm"; import { WAFV2Client, UpdateIPSetCommand, GetIPSetCommand } from "@aws-sdk/client-wafv2"; import { Handler, CloudWatchLogsEvent, CloudWatchLogsLogEvent, CloudWatchLogsDecodedData } from "aws-lambda"; const dynamodbClient = new DynamoDBClient({ region: process.env.AWS_REGION }); const ssmClient = new SSMClient({ region: process.env.AWS_REGION }); const wafClient = new WAFV2Client({ region: process.env.AWS_REGION }); (Lambda) ハンドラー サブスクリプション フィルターを受けて起動する関数なので、引数のイベントは CloudWatchLogsEvent 型になります。全体の処理の流れはこの通りです。 1. ログイベントを抽出 2. IPアドレスをDynamoDBテーブルに追加 3. WAF IPセットのロックトークンを取得(楽観ロック開始) 4. DynamoDBからIPアドレスリストを取得 5. Parameter Storeパラメータを更新 6. WAF IPセットを更新。失敗したら3からやり直す export const handler: Handler = async ( input: CloudWatchLogsEvent ) => { if ( ! process .env.IP_SET_ID || ! process .env.IP_SET_NAME || ! process .env.TABLE_NAME || ! process .env.IP_LIST_PARAMETER_NAME || ! process .env.IP_SET_SCOPE || ! [ "REGIONAL" , "CLOUDFRONT" ] .includes ( process .env.IP_SET_SCOPE ) ) { return; } // 1. ログイベントを抽出(関数の中身は後述) const logEvents = await extractAwsLogEvents ( input ); // 各イベントに対してループ処理 for ( const event of logEvents ) { type LogMessageType = { httpRequest?: { clientIp?: string ; } ; } ; const message = JSON .parse ( event.message ) as LogMessageType ; if ( ! message.httpRequest || ! message.httpRequest.clientIp ) continue; // 2. IPアドレスをDynamoDBテーブルに追加(関数の中身は後述) await addDynamodbItem ( { tableName: process .env.TABLE_NAME , ipAddress: message.httpRequest.clientIp } ); let retryCount = 10 ; while ( retryCount > 0 ) { // 3. WAF IPセットのロックトークンを取得(楽観ロック開始) const { LockToken: lockToken } = await wafClient.send ( new GetIPSetCommand ( { Name: process .env.IP_SET_NAME , Scope: process .env.IP_SET_SCOPE , Id: process .env.IP_SET_ID , } ) ); // 4. DynamoDBからIPアドレスリストを取得(関数の中身は後述) const ipRanges: string [] = await getAllIpsFromDynamodb ( { tableName: process .env.TABLE_NAME } ); const ipRangeParam = ipRanges.length > 0 ? ipRanges.join ( ";" ) : " " ; // 5. Parameter Storeパラメータを更新 await ssmClient.send ( new PutParameterCommand ( { Name: process .env.IP_LIST_PARAMETER_NAME , Value: ipRangeParam , Overwrite: true } ) ); try { // 6. WAF IPセットを更新 // lockToken発行後に更新されているとエラーがThrowされる await wafClient.send ( new UpdateIPSetCommand ( { Name: process .env.IP_SET_NAME , Id: process .env.IP_SET_ID , Scope: process .env.IP_SET_SCOPE , Addresses: ipRanges , LockToken: lockToken , } ) ); retryCount = 0 ; } catch { // 楽観ロックで失敗したら一定回数リトライ retryCount -= 1 ; } } } } ; (Lambda) ログの抽出 ハンドラーの引数からログイベントの中身を抽出する関数は こちら を参考にして実装します。 const extractAwsLogEvents = async ( input: CloudWatchLogsEvent ) : Promise < CloudWatchLogsLogEvent [] > => { const payload = Buffer . from( input.awslogs.data , "base64" ); const result = await new Promise < string >(( resolve , reject ) => { zlib.gunzip ( payload , ( e , result ) => { return e ? reject ( e ) : resolve ( result.toString ( "ascii" )); } ); } ); return ( JSON .parse ( result ) as CloudWatchLogsDecodedData ) .logEvents ; } ; (Lambda) IPアドレス をDynamoDBテーブルに追加 ログに記録された IPアドレス をDynamoDBテーブルにアイテムとして追加する部分は次のようになります。 TTL のフォーマットは Unix エポック時間形式の秒単位 であり、現在時刻より1時間後としています。 const addDynamodbItem = async ( props: { tableName: string ; ipAddress: string } ) : Promise < void > => { // 1時間ブロックする const expires = Math .floor (new Date () .getTime () / 1000 ) + 3600 ; const command = new PutItemCommand ( { TableName: props.tableName , Item: { ip_range: { S: ` ${ props.ipAddress } /32` } , expires: { N: ` ${ expires } ` } , } , } ); await dynamodbClient.send ( command ); } ; (Lambda) DynamoDBから IPアドレス リストを取得 Parameter StoreパラメータとIPセットを更新するために、DynamoDBテーブルをスキャンし全 IPアドレス を取得する部分です。DynamoDBの TTL で有効期限が切れてもすぐにアイテムが削除される保証はなく、最大で48時間テーブルに残るため、スキャン後に フィルター で TTL が有効なアイテムをフィルタリングします。また読み取りは 強力な整合性 とし、直前で追加した IPアドレス が確実に結果に含まれるようにします。(結果整合性よりも多くのキャパシティユニットを消費することにご留意ください) const getAllIpsFromDynamodb = async ( props: { tableName: string } ) : Promise < string [] > => { let startKey: Record < string , AttributeValue > | undefined = undefined ; let shouldScanNext = true ; const ipRanges: string [] = [] ; const currentTime = Math .floor (new Date () .getTime () / 1000 ); while ( shouldScanNext ) { const params: ScanCommandInput = { TableName: props.tableName , ConsistentRead: true , ExpressionAttributeNames: { "#e" : "expires" } , ExpressionAttributeValues: { ":1" : { N: ` ${ currentTime } ` } } , FilterExpression: "#e >= :1" , ExclusiveStartKey: startKey , } ; const { Items , LastEvaluatedKey } = await dynamodbClient.send (new ScanCommand ( params )); Items?.forEach (( item ) => { if ( item [ "ip_range" ] .S ) ipRanges.push ( item [ "ip_range" ] .S ); } ); if ( LastEvaluatedKey ) { startKey = LastEvaluatedKey ; } else { shouldScanNext = false ; } } return ipRanges ; } ; これで、ブロック対象の IPアドレス をIPセットに追加するLambda関数を作成できました。 (Lambda) DynamoDBの IPアドレス リストを同期する関数 EventBridgeを受けて定期的に起動し、DynamoDBの IPアドレス リストをParameter StoreパラメータとIPセットに同期するLambda関数は、先ほどのLambda関数の 3 ~ 6 の処理を利用すれば作成できます。 3. WAF IPセットのロックトークンを取得(楽観ロック開始) 4. DynamoDBからIPアドレスリストを取得 5. Parameter Storeパラメータを更新 6. WAF IPセットを更新。失敗したら3からやり直す 以上で、 AWS WAFでブロックしたリク エス トの IPアドレス を、一定期間 IPアドレス レベルでブロックし続ける仕組みを実現できました。人手を介さずに完全に自動で動き、ブロック対象の IPアドレス は一定期間後に自動消去されるので増え続けることもなく、CDKのデプロイに対してデータが上書きされることもありません。 お読みいただいてありがとうございました! 私たちは同じチームで働いてくれる仲間を大募集しています!たくさんのご応募をお待ちしています。 セキュリティエンジニア(セキュリティ設計) 執筆: @kou.kinyo 、レビュー: 寺山 輝 (@terayama.akira) ( Shodo で執筆されました )
こんにちは、金融ソリューション事業部の若本です。 先日ChatGPT(gpt-3.5-turbo)の API が公開されるとともに、Open AIのサービスが使いやすくなりました。 今回は、ChatGPTから返ってきたレスポンスを読み上げる簡易アプリケーションの作成を行います。 使用するもの 処理概要 1. APIの作成 1.1 Open-AI API(ChatGPT)を介して、質問の答えを取得 1.2 Espnetを用いて、合成音声を生成 2. APIを呼び出すアプリの作成 おわりに 使用するもの OpenAI API 事前に API キーを発行しておく必要があります。また、1000 トーク ン(約700文字程度)単位で費用が発生します。ユーザー登録後、一定まで無料で使えるフリークレジットもあるので、お気軽にお試しいただけます。 ESPNet 有志の方が作成されている音声処理ツールキットです。 音声認識 から 音声合成 まで、幅広い音声タスク/手法が実装されています。 今回は、 音声合成 を使用します。使用する 音声合成 モデルは、 つくよみちゃんコーパスを用いた学習済みモデル です。 fastAPI (任意) python で構築できる API としてfastAPIを使用します。今回は、会話と 音声合成 の処理を API 化します。 Streamlit (任意) python で簡単にアプリを作成できる フレームワーク です。 Docker(任意) 筆者は上記fastAPIとStreamlitの環境を、別々のDocker containerとして構築しました。 処理概要 下記の処理を実装します。 API を用いてAIの推論結果を返せるようにする 質問(Text)を投げ、ChatGPTの返答(Text)を受け取る 文章(Text)を投げ、 音声合成 の結果(Audio)を受け取る 上記の API に処理を依頼する簡易アプリ 今回は 音声合成 の課題である『 音声合成 に時間がかかる問題』に対処します。 音声合成 は、そのモデルの仕組み上、基本的に ストリーミングのようにデータを受け取ることができません 。 そのため、ChatGPTのレスポンスをそのまま 音声合成 してしまうと、数十秒~数分間待たされる可能性があります。 それでは全く使い物にならないため、今回は python の マルチスレッド処理を用いて 音声合成 を並行処理で行います 。 別の 音声合成 の課題として「長文の 音声合成 が安定しない」というのもあり、上記のアプローチはその面でも効果的です。 ただし、最終的な合成の品質をなるべく落とさないために どの区切りで 音声合成 を実行するか を判定する必要があります。 1. API の作成 まず、処理の要となる API を作成します。 今回は保守性向上のため API としているだけですので、必ずしも API にする必要はありません。 fastAPI/Streamlitの基本的な使用方法、およびESPNetの環境構築については割愛します。 1.1 Open-AI API (ChatGPT)を介して、質問の答えを取得 API のRouterにChatGPTを呼び出す関数を記述します。 OpenAIのブログ を参考に、requestライブラリで実施しました。執筆時点(2023/03/07)の情報ですので、適宜request_bodyは変更ください。 他にも OpenAIライブラリで実装する方法 もあります。 from fastapi import APIRouter import requests import json TEMPLATE_PATH = "./data/template.txt" SYSTEM_TEMPLATE_PATH = "./data/system_template.txt" API_KEY = 'YOUR_OPEN_API_KEY' router = APIRouter() template = load_template(TEMPLATE_PATH) system_prompt = load_template(SYSTEM_TEMPLATE_PATH) def load_template(path:str): with open(path, 'r') as f: template = f.read() return template def edit_prompt(template:str, text:str, token="{query}"): prompt = template.replace(token, text) return prompt def prompt_response(prompt:str, system_prompt:str): headers = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + API_KEY, } data = { "model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": prompt}, {"role": "system", "content": system_prompt}], "max_tokens": 200, "temperature": 1, "top_p": 1, } response = requests.post('https://api.openai.com/v1/chat/completions', headers=headers, data=json.dumps(data)) return response.json() def foreprocessing(res): text = res['choices'][0]['message']['content'] return text @router.get("/chat") def get_chat_response(text:str): prompt = edit_prompt(template, text) res = prompt_response(prompt, system_prompt) res_text = foreprocessing(res) return res_text また、この時ChatGPTにクエリを送るためのテンプレートを用意しておきます。 template.txt {query} system_template.txt 文章に対して簡潔に回答してください。 あなたはアシスタントで、ユーザーと会話をしています。 template.txtの内容は "role":"user" に、system_template.txtの内容は "role":"system" にそれぞれ入力として設定されます。今回は "role":"user" にユーザーの入力を、 "role":"system" にChatGPTの振る舞いの指定を実施しました。 {query}は ユーザーの入力で置き換えられます。 今回は 音声合成 時に、あまり長くなく、かつ口語的に返してもらうために振る舞いを指定しておきます。 1.2 Espnetを用いて、合成音声を生成 下記のコードだけで 音声合成 が実行できます。非常にお手軽です。 今回使用しているモデルは VITS (Conditional Variational Autoencoder with Adversarial Learning for End-to-End Text-to-Speech) です。 model_tag で使用するmodelを指定することにより、モデルを自動でダウンロードできます。 代わりの引数として、 model_file に自分で作成したモデルのpathを指定することも可能です(動作確認済み)。 音声合成 モデルを最初から学習するためのレシピもEspnetには整備されていますので、興味のある方はぜひ。 from fastapi import APIRouter from fastapi.responses import StreamingResponse from espnet2.bin.tts_inference import Text2Speech import torch import soundfile as sf import uuid import os router = APIRouter() fs, lang = 44100, "Japanese" text2speech = Text2Speech.from_pretrained( model_tag="kan-bayashi/tsukuyomi_full_band_vits_prosody", device="cpu", # or "cuda" speed_control_alpha=1.0, noise_scale=0.333, noise_scale_dur=0.333, ) def TTS_streamer(text: str): with torch.no_grad(): wav = text2speech(text)["wav"] filename = str(uuid.uuid4()) sf.write(f"{filename}.wav", wav.view(-1).cpu().numpy(), text2speech.fs) with open(f"{filename}.wav", mode="rb") as wav_file: yield from wav_file os.remove(f"{filename}.wav") @router.get("/tts") async def tts_streamer(text: str): return StreamingResponse(TTS_streamer(text), media_type="audio/wav") 上記で API サーバーを起動します。FastAPIにはSwaggerUIも用意されているため、画面上で確認することができます。 API として作成した機能の動作が確認できた後、次のステップに進みます。 2. API を呼び出すアプリの作成 次に、ユーザーの入力と API 処理を繋ぐ簡易アプリを実装します。 ここでは、下記のような処理としました。 ChatGPTのレスポンスを、句読点や改行で区切る ThreadPoolExecutorを用いて API へのリク エス トを並行処理 Threadの実行を順番に待機(+実行時間のスケジュール設定) 音声の再生に必要な秒数分、以降の実行を遅らせる もし音声が遅く届いた場合、遅れた時間だけ以降の実行を遅らせる Chat-GPTの文章を区切り、並行処理で 音声合成 を実行していきます。 Chat-GPTのレスポンスは1文が長い、かつ改行が出現する可能性があるため、区切り文字は句読点と改行としました。 今回は単一 API を同じリソース上で呼び出しているため、並行処理にするメリットは薄いかもしれませんが、実用上は必要な処理になります。 import streamlit as st import requests import base64 import time import re import datetime from concurrent.futures import ThreadPoolExecutor # 音声合成の最小文字数。小さすぎると安定しない場合があります。 SPLIT_THRESHOLD = 4 # 息継ぎの秒数(s) TIME_BUFFER = 0.1 # 待機中の実行スパン(s) SLEEP_ITER = 0.2 # APIのそれぞれのURL CHATBOT_ENDPOINT = 'http://chatbot-backend:8000/chat' TTS_ENDPOINT = 'http://chatbot-backend:8000/tts' def split_text(text:str): text_list = re.split('[\n、。]+', text) text_list_ = [] for text in text_list: if text == '': continue if len(text) < SPLIT_THRESHOLD: try: text_list_[-1] = text_list_[-1] + '。' + text except IndexError: text_list_.append(text) else: text_list_.append(text) if len(text_list[0]) < SPLIT_THRESHOLD and len(text_list_) > 1: text_list_[1] = text_list[0] + '。' + text_list_[1] text_list_ = text_list_[1:] return text_list_ def get_tts_sound(text:str, url=TTS_ENDPOINT): params = {'text': text} response = requests.get(url, params=params) return response.content, datetime.datetime.now() def sound_player(response_content:str): # 参考:https://qiita.com/kunishou/items/a0a1a26449293634b7a0 audio_placeholder = st.empty() audio_str = "data:audio/ogg;base64,%s"%(base64.b64encode(response_content).decode()) audio_html = """ <audio autoplay=True> <source src="%s" type="audio/ogg" autoplay=True> Your browser does not support the audio element. </audio> """ %audio_str audio_placeholder.empty() time.sleep(0.5) audio_placeholder.markdown(audio_html, unsafe_allow_html=True) def get_chat_response(text:str, url=CHATBOT_ENDPOINT): params = {'text': text} response = requests.get(url, params=params) return response.text if __name__ == "__main__": st.set_page_config(layout="wide") query = st.text_input('質問を入力してください') button = st.button('実行') if button: # チャットボットの返信を取得 response_text = get_chat_response(query) st.write(f'回答:{response_text}') # 返信を分割 split_response = split_text(response_text) executor = ThreadPoolExecutor(max_workers=2) futures = [] # 並行処理として音声合成へ for sq_text in split_response: future = executor.submit(get_tts_sound, sq_text) futures.append(future) block_time_list = [datetime.timedelta() for i in range(len(futures))] current_time = datetime.datetime.now() # 結果をwaitし、再生可能時間になり次第再生する res_index = 0 gap_time = datetime.timedelta() while res_index < len(futures): future = futures[res_index] if future.done(): if res_index==0: base_time = datetime.datetime.now() if datetime.datetime.now() > base_time + block_time_list[res_index]: for i in range(len(block_time_list)): if i > res_index: # 音声長を計算。音声は32bitの16000Hz、base64エンコードの結果は1文字6bitの情報であるため、下記の計算で算出できます block_time_list[i] += datetime.timedelta(seconds=(len(future.result()[0])*6/32/16000)+gap_time.total_seconds()+TIME_BUFFER) st.write(f' 実行完了:{split_response[res_index]}') st.write(f' 実行時間:{(future.result()[1] - current_time).total_seconds():.3f}s') st.write(f' 音声の長さ:{len(future.result()[0])*6/32/16000:.3f}s') sound_player(future.result()[0]) res_index += 1 gap_time = datetime.timedelta() elif res_index!=0: gap_time += datetime.timedelta(seconds=SLEEP_ITER) time.sleep(SLEEP_ITER) executor.shutdown() 早速結果を見てみましょう。 リアルタイムとはいきませんでしたが、概ね許容範囲です。 なお、こちらは筆者の GPU 非搭載ノートPCで実行しています。今回は簡易的な検証でしたので、モデルの圧縮、コードの簡素化、高スペックサーバーの使用など、高速化できる余地は多々あります。 並行実行することにより、繋ぎ目となる文章の箇所に違和感が残る懸念もありましたが、あまり違和感は感じられませんでした。 返ってくる結果が遅い場合には、 フィラー (「えーと」「その」など)を入れる判定を噛ませることで自然になりそうです。 おわりに ChatGPTとESPNetを用いて、会話の返答を音声で出力する簡易アプリケーションを作成しました。 OpenAIは、ChatGPTの他にもWhisperという 音声認識 モデルを API として公開していますので、連結することで音声だけで会話することも可能です。 映画アイアンマンのJ.A.R.V.I.S.に憧れてAIの勉強を始めたので、夢見た未来がだいぶ近づいてきたなと感じます。このようなAIが親しみやすい形で生活に浸透していくのか、今後も注視していきます。 (追記:記事公開前にGPT-4がローンチされました。AI領域の進歩の加速を感じる昨今です。) それでは最後に。 執筆: @wakamoto.ryosuke 、レビュー: Ishizawa Kento (@kent) ( Shodo で執筆されました )
こんにちは、金融ソリューション事業部の若本です。 先日ChatGPT(gpt-3.5-turbo)の API が公開されるとともに、Open AIのサービスが使いやすくなりました。 今回は、ChatGPTから返ってきたレスポンスを読み上げる簡易アプリケーションの作成を行います。 使用するもの 処理概要 1. APIの作成 1.1 Open-AI API(ChatGPT)を介して、質問の答えを取得 1.2 Espnetを用いて、合成音声を生成 2. APIを呼び出すアプリの作成 おわりに 使用するもの OpenAI API 事前に API キーを発行しておく必要があります。また、1000 トーク ン(約700文字程度)単位で費用が発生します。ユーザー登録後、一定まで無料で使えるフリークレジットもあるので、お気軽にお試しいただけます。 ESPNet 有志の方が作成されている音声処理ツールキットです。 音声認識 から 音声合成 まで、幅広い音声タスク/手法が実装されています。 今回は、 音声合成 を使用します。使用する 音声合成 モデルは、 つくよみちゃんコーパスを用いた学習済みモデル です。 fastAPI (任意) python で構築できる API としてfastAPIを使用します。今回は、会話と 音声合成 の処理を API 化します。 Streamlit (任意) python で簡単にアプリを作成できる フレームワーク です。 Docker(任意) 筆者は上記fastAPIとStreamlitの環境を、別々のDocker containerとして構築しました。 処理概要 下記の処理を実装します。 API を用いてAIの推論結果を返せるようにする 質問(Text)を投げ、ChatGPTの返答(Text)を受け取る 文章(Text)を投げ、 音声合成 の結果(Audio)を受け取る 上記の API に処理を依頼する簡易アプリ 今回は 音声合成 の課題である『 音声合成 に時間がかかる問題』に対処します。 音声合成 は、そのモデルの仕組み上、基本的に ストリーミングのようにデータを受け取ることができません 。 そのため、ChatGPTのレスポンスをそのまま 音声合成 してしまうと、数十秒~数分間待たされる可能性があります。 それでは全く使い物にならないため、今回は python の マルチスレッド処理を用いて 音声合成 を並行処理で行います 。 別の 音声合成 の課題として「長文の 音声合成 が安定しない」というのもあり、上記のアプローチはその面でも効果的です。 ただし、最終的な合成の品質をなるべく落とさないために どの区切りで 音声合成 を実行するか を判定する必要があります。 1. API の作成 まず、処理の要となる API を作成します。 今回は保守性向上のため API としているだけですので、必ずしも API にする必要はありません。 fastAPI/Streamlitの基本的な使用方法、およびESPNetの環境構築については割愛します。 1.1 Open-AI API (ChatGPT)を介して、質問の答えを取得 API のRouterにChatGPTを呼び出す関数を記述します。 OpenAIのブログ を参考に、requestライブラリで実施しました。執筆時点(2023/03/07)の情報ですので、適宜request_bodyは変更ください。 他にも OpenAIライブラリで実装する方法 もあります。 from fastapi import APIRouter import requests import json TEMPLATE_PATH = "./data/template.txt" SYSTEM_TEMPLATE_PATH = "./data/system_template.txt" API_KEY = 'YOUR_OPEN_API_KEY' router = APIRouter() template = load_template(TEMPLATE_PATH) system_prompt = load_template(SYSTEM_TEMPLATE_PATH) def load_template(path:str): with open(path, 'r') as f: template = f.read() return template def edit_prompt(template:str, text:str, token="{query}"): prompt = template.replace(token, text) return prompt def prompt_response(prompt:str, system_prompt:str): headers = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + API_KEY, } data = { "model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": prompt}, {"role": "system", "content": system_prompt}], "max_tokens": 200, "temperature": 1, "top_p": 1, } response = requests.post('https://api.openai.com/v1/chat/completions', headers=headers, data=json.dumps(data)) return response.json() def foreprocessing(res): text = res['choices'][0]['message']['content'] return text @router.get("/chat") def get_chat_response(text:str): prompt = edit_prompt(template, text) res = prompt_response(prompt, system_prompt) res_text = foreprocessing(res) return res_text また、この時ChatGPTにクエリを送るためのテンプレートを用意しておきます。 template.txt {query} system_template.txt 文章に対して簡潔に回答してください。 あなたはアシスタントで、ユーザーと会話をしています。 template.txtの内容は "role":"user" に、system_template.txtの内容は "role":"system" にそれぞれ入力として設定されます。今回は "role":"user" にユーザーの入力を、 "role":"system" にChatGPTの振る舞いの指定を実施しました。 {query}は ユーザーの入力で置き換えられます。 今回は 音声合成 時に、あまり長くなく、かつ口語的に返してもらうために振る舞いを指定しておきます。 1.2 Espnetを用いて、合成音声を生成 下記のコードだけで 音声合成 が実行できます。非常にお手軽です。 今回使用しているモデルは VITS (Conditional Variational Autoencoder with Adversarial Learning for End-to-End Text-to-Speech) です。 model_tag で使用するmodelを指定することにより、モデルを自動でダウンロードできます。 代わりの引数として、 model_file に自分で作成したモデルのpathを指定することも可能です(動作確認済み)。 音声合成 モデルを最初から学習するためのレシピもEspnetには整備されていますので、興味のある方はぜひ。 from fastapi import APIRouter from fastapi.responses import StreamingResponse from espnet2.bin.tts_inference import Text2Speech import torch import soundfile as sf import uuid import os router = APIRouter() fs, lang = 44100, "Japanese" text2speech = Text2Speech.from_pretrained( model_tag="kan-bayashi/tsukuyomi_full_band_vits_prosody", device="cpu", # or "cuda" speed_control_alpha=1.0, noise_scale=0.333, noise_scale_dur=0.333, ) def TTS_streamer(text: str): with torch.no_grad(): wav = text2speech(text)["wav"] filename = str(uuid.uuid4()) sf.write(f"{filename}.wav", wav.view(-1).cpu().numpy(), text2speech.fs) with open(f"{filename}.wav", mode="rb") as wav_file: yield from wav_file os.remove(f"{filename}.wav") @router.get("/tts") async def tts_streamer(text: str): return StreamingResponse(TTS_streamer(text), media_type="audio/wav") 上記で API サーバーを起動します。FastAPIにはSwaggerUIも用意されているため、画面上で確認することができます。 API として作成した機能の動作が確認できた後、次のステップに進みます。 2. API を呼び出すアプリの作成 次に、ユーザーの入力と API 処理を繋ぐ簡易アプリを実装します。 ここでは、下記のような処理としました。 ChatGPTのレスポンスを、句読点や改行で区切る ThreadPoolExecutorを用いて API へのリク エス トを並行処理 Threadの実行を順番に待機(+実行時間のスケジュール設定) 音声の再生に必要な秒数分、以降の実行を遅らせる もし音声が遅く届いた場合、遅れた時間だけ以降の実行を遅らせる Chat-GPTの文章を区切り、並行処理で 音声合成 を実行していきます。 Chat-GPTのレスポンスは1文が長い、かつ改行が出現する可能性があるため、区切り文字は句読点と改行としました。 今回は単一 API を同じリソース上で呼び出しているため、並行処理にするメリットは薄いかもしれませんが、実用上は必要な処理になります。 import streamlit as st import requests import base64 import time import re import datetime from concurrent.futures import ThreadPoolExecutor # 音声合成の最小文字数。小さすぎると安定しない場合があります。 SPLIT_THRESHOLD = 4 # 息継ぎの秒数(s) TIME_BUFFER = 0.1 # 待機中の実行スパン(s) SLEEP_ITER = 0.2 # APIのそれぞれのURL CHATBOT_ENDPOINT = 'http://chatbot-backend:8000/chat' TTS_ENDPOINT = 'http://chatbot-backend:8000/tts' def split_text(text:str): text_list = re.split('[\n、。]+', text) text_list_ = [] for text in text_list: if text == '': continue if len(text) < SPLIT_THRESHOLD: try: text_list_[-1] = text_list_[-1] + '。' + text except IndexError: text_list_.append(text) else: text_list_.append(text) if len(text_list[0]) < SPLIT_THRESHOLD and len(text_list_) > 1: text_list_[1] = text_list[0] + '。' + text_list_[1] text_list_ = text_list_[1:] return text_list_ def get_tts_sound(text:str, url=TTS_ENDPOINT): params = {'text': text} response = requests.get(url, params=params) return response.content, datetime.datetime.now() def sound_player(response_content:str): # 参考:https://qiita.com/kunishou/items/a0a1a26449293634b7a0 audio_placeholder = st.empty() audio_str = "data:audio/ogg;base64,%s"%(base64.b64encode(response_content).decode()) audio_html = """ <audio autoplay=True> <source src="%s" type="audio/ogg" autoplay=True> Your browser does not support the audio element. </audio> """ %audio_str audio_placeholder.empty() time.sleep(0.5) audio_placeholder.markdown(audio_html, unsafe_allow_html=True) def get_chat_response(text:str, url=CHATBOT_ENDPOINT): params = {'text': text} response = requests.get(url, params=params) return response.text if __name__ == "__main__": st.set_page_config(layout="wide") query = st.text_input('質問を入力してください') button = st.button('実行') if button: # チャットボットの返信を取得 response_text = get_chat_response(query) st.write(f'回答:{response_text}') # 返信を分割 split_response = split_text(response_text) executor = ThreadPoolExecutor(max_workers=2) futures = [] # 並行処理として音声合成へ for sq_text in split_response: future = executor.submit(get_tts_sound, sq_text) futures.append(future) block_time_list = [datetime.timedelta() for i in range(len(futures))] current_time = datetime.datetime.now() # 結果をwaitし、再生可能時間になり次第再生する res_index = 0 gap_time = datetime.timedelta() while res_index < len(futures): future = futures[res_index] if future.done(): if res_index==0: base_time = datetime.datetime.now() if datetime.datetime.now() > base_time + block_time_list[res_index]: for i in range(len(block_time_list)): if i > res_index: # 音声長を計算。音声は32bitの16000Hz、base64エンコードの結果は1文字6bitの情報であるため、下記の計算で算出できます block_time_list[i] += datetime.timedelta(seconds=(len(future.result()[0])*6/32/16000)+gap_time.total_seconds()+TIME_BUFFER) st.write(f' 実行完了:{split_response[res_index]}') st.write(f' 実行時間:{(future.result()[1] - current_time).total_seconds():.3f}s') st.write(f' 音声の長さ:{len(future.result()[0])*6/32/16000:.3f}s') sound_player(future.result()[0]) res_index += 1 gap_time = datetime.timedelta() elif res_index!=0: gap_time += datetime.timedelta(seconds=SLEEP_ITER) time.sleep(SLEEP_ITER) executor.shutdown() 早速結果を見てみましょう。 リアルタイムとはいきませんでしたが、概ね許容範囲です。 なお、こちらは筆者の GPU 非搭載ノートPCで実行しています。今回は簡易的な検証でしたので、モデルの圧縮、コードの簡素化、高スペックサーバーの使用など、高速化できる余地は多々あります。 並行実行することにより、繋ぎ目となる文章の箇所に違和感が残る懸念もありましたが、あまり違和感は感じられませんでした。 返ってくる結果が遅い場合には、 フィラー (「えーと」「その」など)を入れる判定を噛ませることで自然になりそうです。 おわりに ChatGPTとESPNetを用いて、会話の返答を音声で出力する簡易アプリケーションを作成しました。 OpenAIは、ChatGPTの他にもWhisperという 音声認識 モデルを API として公開していますので、連結することで音声だけで会話することも可能です。 映画アイアンマンのJ.A.R.V.I.S.に憧れてAIの勉強を始めたので、夢見た未来がだいぶ近づいてきたなと感じます。このようなAIが親しみやすい形で生活に浸透していくのか、今後も注視していきます。 (追記:記事公開前にGPT-4がローンチされました。AI領域の進歩の加速を感じる昨今です。) それでは最後に。 執筆: @wakamoto.ryosuke 、レビュー: Ishizawa Kento (@kent) ( Shodo で執筆されました )
こんにちは!金融ソリューション事業部の山下です。 本記事では、 Unreal Engine のPluginである OnlineSubsystem を利用して、インターネット経由で同時接続するオンライン マルチプレイ 機能を C++ で実装する手順を紹介します。 前提知識 ネットワークモデル ゲームサーバー/ゲームクライアント UEにおけるゲームサーバー方式 ゲームセッション オンラインサービス OnlineSubsystem 実施手順 実施環境/ツール 1. UEプロジェクト作成と各種設定 2. Session Interfaceの作成 3. CreateSession()の実装 4. JoinGameSession()の実装 5. 端末2台を用いた接続確認 所感 参考 前提知識 ネットワークモデル オンラインゲームにおけるネットワークモデルには複数の選択肢があります。 Unreal Engine では、基本的に「Client-Server」モデルが採用されております。 Peer-To-Peerモデル:プレイヤー同士でゲーム情報を相互通信する方式。プレイヤー数の増加に伴い通信が増大してしまう。また、ゲーム内に「唯一の正しいステート」が存在しない為、緻密な判定やプレイ精度が求められるゲームには不向きです。 Client-Serverモデル:「唯一の正しいステート」を持つゲームサーバーに対して、ゲームクライアントが接続する方式。ゲームクライアントから送られた情報は、ゲームサーバー経由で各ゲームクライアントにBroadCastされます。 ゲームサーバー/ゲームクライアント よく混同されますが、 Webサービス におけるWebサーバーとWebクライアントとは異なります。 ゲームサーバー:Client-Serverモデルにおける、「唯一の正しいステート」を持つサーバーです。 ゲームクライアント:Client-Serverモデルにおける、ゲームを実行するクライアントです。 UEにおけるゲームサーバー方式 Unrealn Engine では、以下2種類のゲームサーバー方式が利用可能です。 ListenServer:ゲームサーバー上でグラフィックの レンダリング をします。特定のプレイヤーがゲームサーバーを兼ねることにより、運営リソースを節約できます。一方で、ユーザーの端末スペックに依存してしまうこと、また多人数のゲームには不向きである点が欠点です。 DedicatedServer:ゲームサーバー上でグラフィックの レンダリング を行わいません。運営側でサーバーを用意する必要があり、ユーザー数の増加に伴いインフラコストもかかりますが、多人数のゲームにも対応可能です。 ゲームセッション よく混同されますが、ゲームセッションと Webサービス のセッションは異なります。 ゲームセッションは、具体的にはゲームサーバー上で動作するゲーム インスタンス を指します。 複数のプレイヤーが同一のゲームセッションに接続することで、「同じゲーム空間の共有 = マルチプレイ 」が可能になります。 オンラインサービス 一般的なオンライン マルチプレイ ゲームでは、UserやSession、AchievementやFriendなどの機能が必要になります。 そこでサービスプラットフォーム(Steam、 Xbox live 、 Facebook など)では、このような機能がオンラインサービスとして提供されております。 サービスプラットフォームを利用せずに自前で構築することももちろん可能です。例えば AWS では GameLift などのサービスも提供しており、DedicatedServerの ホスティング に加えてオンラインサービスの提供もされています。 AWS GameLift の利用方法については、 孫さんの記事 をぜひご覧ください。 OnlineSubsystem Unreal Engine が提供するPluginです。 各オンラインサービスプラットフォームにアクセスする為の共通モジュールおよびインターフェースが提供されています。 Steam、 Xbox live 、 Facebook 、EOSなど マルチプラットフォーム のゲームが、基本的にはコンフィギューレーションを調整するだけで1コードベースで マルチプラットフォーム の実装が可能です。 インターフェースには、Session、Friends, Achievementsなどが提供されています。 本記事では、基本的なCreateSession()とFindSessions()、JoinSession()を用います。 詳細は以下をご覧ください。 https://docs.unrealengine.com/5.1/en-US/API/Plugins/OnlineSubsystem/Interfaces/IOnlineSession/ 実施手順 今回は、検証を簡易にするためListenServer方式で検証を行います。 また、セッション管理を行うオンラインサービスについては、開発用IDが無償提供されているSteamネットワークを使用します。 UEプロジェクト作成と各種設定 Session Interfaceの作成 CreateSession()の実装 JoinGameSession()の実装 端末2台を用いた接続確認 実施環境/ツール OS: Windows 11 pro GPU : NVIDIA GeForce RTX 3070Ti Laptop DCC: Adobe Substance 3D Sampler 3.4.1 Game Engine: Unreal Engine 5.1.0 1. UEプロジェクト作成と各種設定 Unreal Engine のNew Project > Third Personテンプレートを選択します。Project Defaultsで、 C++ を選択します。 今回、プロジェクト名は「OnlineMultiplaySteam」としました。 Edit >Pluginを開きます。 「Online Subsystem Steam」を選択します。 Restartが求められるので再起動します。 次に、 Visual Studio エディタに移ります。 SolusionExplorerで以下のファイルを開きます。 Games > OnlineMultiplaySteam > Source > OnlineMultiplaySteam > OnlineMultiplaySteam.Build.cs PublicDependencyModuleに"OnlineSubsystemSteam", "OnlineSubsystem"を追加します。 11行目を以下に書き換え、Buildします。 PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", "OnlineSubsystemSteam", "OnlineSubsystem" }); 次に、DefaultEngine.iniを修正します。 SolusionExplorerで以下のファイルを開きます。 Games > OnlineMultiplaySteam > Config > DefaultEngine.ini こちらのUEドキュメント を参考に、以下を追記します。 [/Script/Engine.GameEngine] +NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="OnlineSubsystemSteam.SteamNetDriver",DriverClassNameFallback="OnlineSubsystemUtils.IpNetDriver") [OnlineSubsystem] DefaultPlatformService=Steam [OnlineSubsystemSteam] bEnabled=true SteamDevAppId=480 [/Script/OnlineSubsystemSteam.SteamNetDriver] NetConnectionClassName="OnlineSubsystemSteam.SteamNetConnection" 今回使用するSteamDevAppIdは480となっております。これはSteamから開発用に提供されているサンプルゲーム(SpaceWar)のIDです。本番開発で用いる場合は、自身でAppIdを取得する必要がありますのでご注意ください。 最後にプロジェクトファイルを生成します。 Editorを閉じて、File Explorer で「Saved」「Intermidiate」「Binaries」ファイルを削除します。その後、「Generate Visual Studio project files」でプロジェクトファイルを生成します。 これでプロジェクト設定は完了です。 2. Session Interfaceの作成 ThirdPersonTemplateのCharacterクラスを修正します。 Unreal Engine 独自の プレフィックス (クラス名にF,A、型名にF,Uなど)については、 公式のコーディング規約 をご参照ください。 OnlineMultiplaySteamCharacter.hを編集します。 includeに以下を追加します。 #include "Interfaces/OnlineSessionInterface.h" 記載する行について、"....generated.h"が一番最下部になる点にはご注意ください。 class内に、以下を追加します。 public: IOnlineSessionPtr OnlineSessionInterface; SessionInterfaceの変数が宣言できました。 IOnlineSession インターフェイス 仕様は、以下を参照してください。 https://docs.unrealengine.com/5.1/en-US/API/Plugins/OnlineSubsystem/Interfaces/IOnlineSession/ OnlineMultiplaySteamCharacter.cppを編集します。 includeに以下を追加します。 #include "OnlineSubsystem.h" コンスト ラク タの最下部に、以下を追記します。 IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get(); if (OnlineSubsystem)    {     OnlineSessionInterface = OnlineSubsystem->GetSessionInterface();     if (GEngine) { GEngine->AddOnScreenDebugMessage( 15.f, Color::Blue, String::Printf(TEXT("Found subsystem %s"), *OnlineSubsystem->GetSubsystemName().ToString()) ); } } OnlineSubsystemを使って、事前に指定したSteamと接続するためのInterfaceを取得しています。 本処理はCharacterクラスに追記しているため、キャ ラク ターがレベルにSpawnするタイミングでSessionInterfaceが作成されることになります。 IOnlineSubsystemの インターフェイス 仕様は、以下をご確認ください。 https://docs.unrealengine.com/5.1/en-US/API/Plugins/OnlineSubsystem/IOnlineSubsystem/ ビルドしてゲームを立ち上げます。 以下のように左側に青字のメッセージが表示されることを確認します。 SessionInterfaceが無事作成され、Steamネットワークに接続ができました。 3. CreateSession()の実装 作成したSessionInterfaceを使って、ゲームセッションの作成を行います。 事前に、セッション作成後の移動先レベルを作っておきます。レベルはDafaultのままで、名称はLobbyとします。 OnlineMultiplaySteamCharacter.hを編集します。 BluePrintでイベント(今回はKey1押下イベント)を受け取ってセッションを実行する為に、 protectedセクションを作成して以下BlueprintCallable関数を追加します。 protected: UFUNCTION(BlueprintCallable) void CreateGameSession(); OnlineSessionInterfaceでCreateGameSesion()を実行後、コールバック関数であるOnCreateSessionComplete()を実行します。 同じくprotectedセクションに、セッション作成後に呼びだすコールバック関数も追加します。 void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful); privateセクションを追加して、コールバック関数をBindするためのDelegete変数も追加します。 private: FOnCreateSessionCompleteDelegate CreateSessionCompleteDelegate; 続いて、.cppファイルにDelegete変数をコールバック関数とbindした上で、OnlineSessionInterfaceの Delegate ハンドラーに登録していきます。 OnlineMultiplaySteamCharacter.cppに以下を追加します。 コンスト ラク タの冒頭に、以下のように Delegate 変数にコールバック関数をBindします。 AOnlineMultiplaySteamCharacter::AOnlineMultiplaySteamCharacter(): CreateSessionCompleteDelegate(FOnCreateSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnCreateSessionComplete)) CreateGameSession ()を追加します。 少し長いコードになってきたので、折りたたみ表示にします。詳細はコメントをご確認ください。 CreateGameSession() void AOnlineMultiplaySteamCharacter::CreateGameSession() { // called when pressed 1 key if (!OnlineSessionInterface.IsValid()) { return; } // check existing session auto ExistingSession = OnlineSessionInterface->GetNamedSession(NAME_GameSession); if (ExistingSession != nullptr) { OnlineSessionInterface->DestroySession(NAME_GameSession); } // Add Delegete variable to OnlineSessionInterface OnlineSessionInterface->AddOnCreateSessionCompleteDelegate_Handle(CreateSessionCompleteDelegate); // Create Session Settings TSharedPtr <FOnlineSessionSettings> SessionSettings = MakeShareable(new FOnlineSessionSettings()); SessionSettings->bIsLANMatch = false; SessionSettings->NumPublicConnections = 4; SessionSettings->bAllowJoinInProgress = true; SessionSettings->bAllowJoinViaPresence = true; SessionSettings->bShouldAdvertise = true; SessionSettings->bUsesPresence = true; SessionSettings->bUseLobbiesIfAvailable = true; SessionSettings->Set(FName("MatchType"), FString("FreeForAll"), EOnlineDataAdvertisementType::ViaOnlineServiceAndPing); //Create Session const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController(); OnlineSessionInterface->CreateSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, *SessionSettings); } SessionSettingsを用意してSessionInterface->CreateSession関数を呼んでいます。 また、コンスト ラク タでも触れた Delegate 変数をハンドラーに登録しています。 SessionSettingのパラメーターは こちら を参照してください。 セッション作成後に呼びだすコールバック関数を実装します。 OnCreateSessionComplete() void AOnlineMultiplaySteamCharacter::OnCreateSessionComplete(FName SessionName, bool bWasSuccessful) { if (bWasSuccessful) { if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, 15.f, FColor::Blue, FString::Printf(TEXT("Successsfully Created session: %s"), *SessionName.ToString()) ); } UWorld* World = GetWorld(); if (World) { World->ServerTravel(FString("/Game/ThirdPerson/Maps/Lobby?Listen")); } } else { if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, 15.f, FColor::Red, FString(TEXT("Failed to create session!")) ); } } セッション作成完了後にシンプルなログメッセージを表示しています。 またLobbyレベルへの移動は、World->ServerTravel()関数を用いました。 オプションパラメーターでListenサーバーを指定しています。 イベントを受け取るために、EditorでThirdPersonCharacterのBPを開いて以下ノードを追加します。 これで、Key1を押下すると、BluePrint Callableで実装したCreate Game Session関数が実行されます。 コンパイル 後、ゲームを立ち上げます。 Key1を押下します。 成功ログが画面に表示されれば、セッション作成は完了です。 これで、ListenServer方式のゲームサーバー側の処理は完了しました。 注意:オンラインシステムへの接続を試す際、ゲームをEditor Viewportで実行しても正しく接続されません。Standsloneモード or パッケージ化した上で実行してください。 4. JoinGameSession()の実装 BluePrintでイベント(こちらはKey2押下イベント)を受け取ってセッションに参加する処理を実装します。 OnlineMultiplaySteamCharacter.hを編集します。 protectedセクションを作成して以下BlueprintCallable関数を追加します。 protected: UFUNCTION(BlueprintCallable) void JoinGameSession(); このJoinGameSesion()の中では、「セッションの検索」と「セッションへの参加」の2つの処理を行います。 その為、今回はそれぞれのコールバック関数を2つ用意します。 void OnFindSessionsComplete(bool bWasSuccessful); void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result); privateセクションに、Delegete変数も2つ追加します。 また、検索条件を格納する為のSharedPointerも追加します。 FOnFindSessionsCompleteDelegate FindSessionsCompleteDelegate; FOnJoinSessionCompleteDelegate JoinSessionCompleteDelegate; // Search Setting TSharedPtr<FOnlineSessionSearch> SessionSearch; 続いてOnlineMultiplaySteamCharacter.cppを編集します。 includeに以下を追加します。 #include "OnlineSessionSettings.h" 3.と同様にコンスト ラク タにて、 Delegate 変数にコールバック関数をbindします。 FindSessionsCompleteDelegate(FOnFindSessionsCompleteDelegate::CreateUObject(this, &ThisClass::OnFindSessionsComplete)), JoinSessionCompleteDelegate(FOnJoinSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnJoinSessionComplete)) 以下関数を実装します。 void JoinGameSession(); void OnFindSessionsComplete(); void OnJoinSessionComplete(); こちらもそれぞれ折りたたみ表示にします。詳細はコメントをご確認ください。 まずJoinGameSession()では、検索条件であるSessionSearchを設定してFindSessionsを実行します。 SessionSearchのパラメーターは こちら をご覧ください。 JoinGameSession() void AOnlineMultiplaySteamCharacter::JoinGameSession() { // called when pressing 2 key if (!OnlineSessionInterface.IsValid()) { return; } if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, 15.f, FColor::Blue, FString::Printf(TEXT("pressed 2 and Executed function: JoinGameSession()")) ); } OnlineSessionInterface->AddOnFindSessionsCompleteDelegate_Handle(FindSessionsCompleteDelegate); SessionSearch = MakeShareable(new FOnlineSessionSearch()); SessionSearch->MaxSearchResults = 10000; SessionSearch->bIsLanQuery = false; SessionSearch->QuerySettings.Set(SEARCH_PRESENCE, true, EOnlineComparisonOp::Equals); const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController(); OnlineSessionInterface->FindSessions(*LocalPlayer->GetPreferredUniqueNetId(), SessionSearch.ToSharedRef()); } OnFindSessionsComplete()は、FindSessions()実行後のコールバック関数です。 検索結果のSession情報を用いて、セッションに参加します。 今回利用しているSteamAppIDは世界中の人が使っている為、SessionSettingで定義したMatchTypeにフィルタリングをしています。取得したセッション情報を引数に、JoinSession()を実行します。 OnFindSessionsComplete() void AOnlineMultiplaySteamCharacter::OnFindSessionsComplete(bool bWasSuccessful) { if (!OnlineSessionInterface.IsValid()) { return; } if (bWasSuccessful) { GEngine->AddOnScreenDebugMessage(-1,15.f,FColor::Cyan, FString::Printf(TEXT("FindSession Complete! SearchResults.Num() = %d"), SessionSearch->SearchResults.Num())); for (auto Result : SessionSearch->SearchResults) { FString Id = Result.GetSessionIdStr(); FString User = Result.Session.OwningUserName; FString MatchType; Result.Session.SessionSettings.Get(FName("MatchType"), MatchType); if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, 15.f, FColor::Cyan, FString::Printf(TEXT("Successsfully Find Session! Id: %s , OwningUser: %s"), *Id, *User) ); } if (MatchType == FString("FreeForAll")) { if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, 15.f, FColor::Cyan, FString::Printf(TEXT("Joining Match Type: %s"), *MatchType) ); } OnlineSessionInterface->AddOnJoinSessionCompleteDelegate_Handle(JoinSessionCompleteDelegate); const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController(); OnlineSessionInterface->JoinSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, Result); } } } else { if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, 15.f, FColor::Red, FString(TEXT("Failed to join session!")) ); } } } OnJoinSessionComplete()では、セッションへの参加後、セッション情報から IPアドレス を取得してレベルに移動します。 レベルの移動には、PlayerController->ClientTravel()を用います。 OnJoinSessionComplete() void AOnlineMultiplaySteamCharacter::OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result) { if (!OnlineSessionInterface.IsValid()) { return; } FString Address; if (OnlineSessionInterface->GetResolvedConnectString(NAME_GameSession, Address)) { if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, 15.f, FColor::Yellow, FString::Printf(TEXT("Connect string: %s"), *Address) ); } APlayerController* PlayerController = GetGameInstance()->GetFirstLocalPlayerController(); if (PlayerController) { PlayerController->ClientTravel(Address, ETravelType::TRAVEL_Absolute); } } } 最後に、EditorでThirdPersonCharacterのBPを開いて以下ノードを追加します。 5. 端末2台を用いた接続確認 マルチプレイ の検証のために、 Windows マシンを2つ用意します。 また、Steam IDも2つ用意します(Steam IDが同一だとセッションに入れない為)。 各Steamアカウントの設定で、Download Regionを同一にする必要がある点にもご注意ください。 1台目で、Key1を押下します。 2台目で、Key2を押下します。 操作をしてみて、動きが正しく同期されていることを確認します。 無事、オンライン経由で マルチプレイ を実現しました。 所感 今回は、OnlineSubSystemを用いて、インターネット経由のオンライン マルチプレイ を実装しました。 基本的には Unreal Engine で提供されているOnlineSubsystem Pluginを用いることで実現可能なため、単純な ユースケース であれば比較的実装はしやすい印象でした。 以前紹介したPixelStreaming と組み合わせることで、今回用意したような高スペックなマシン不要で、オンライン マルチプレイ を実現できます。 これらについては今後も検証していきたいと思います。 以前の記事 でも紹介したように、現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ご連絡ください。 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ 参考 https://docs.unrealengine.com/5.1/en-US/online-subsystem-steam-interface-in-unreal-engine/ 執筆: @yamashita.yuki 、レビュー: @wakamoto.ryosuke ( Shodo で執筆されました )
こんにちは!金融ソリューション事業部の山下です。 本記事では、 Unreal Engine のPluginである OnlineSubsystem を利用して、インターネット経由で同時接続するオンライン マルチプレイ 機能を C++ で実装する手順を紹介します。 前提知識 ネットワークモデル ゲームサーバー/ゲームクライアント UEにおけるゲームサーバー方式 ゲームセッション オンラインサービス OnlineSubsystem 実施手順 実施環境/ツール 1. UEプロジェクト作成と各種設定 2. Session Interfaceの作成 3. CreateSession()の実装 4. JoinGameSession()の実装 5. 端末2台を用いた接続確認 所感 参考 前提知識 ネットワークモデル オンラインゲームにおけるネットワークモデルには複数の選択肢があります。 Unreal Engine では、基本的に「Client-Server」モデルが採用されております。 Peer-To-Peerモデル:プレイヤー同士でゲーム情報を相互通信する方式。プレイヤー数の増加に伴い通信が増大してしまう。また、ゲーム内に「唯一の正しいステート」が存在しない為、緻密な判定やプレイ精度が求められるゲームには不向きです。 Client-Serverモデル:「唯一の正しいステート」を持つゲームサーバーに対して、ゲームクライアントが接続する方式。ゲームクライアントから送られた情報は、ゲームサーバー経由で各ゲームクライアントにBroadCastされます。 ゲームサーバー/ゲームクライアント よく混同されますが、 Webサービス におけるWebサーバーとWebクライアントとは異なります。 ゲームサーバー:Client-Serverモデルにおける、「唯一の正しいステート」を持つサーバーです。 ゲームクライアント:Client-Serverモデルにおける、ゲームを実行するクライアントです。 UEにおけるゲームサーバー方式 Unrealn Engine では、以下2種類のゲームサーバー方式が利用可能です。 ListenServer:ゲームサーバー上でグラフィックの レンダリング をします。特定のプレイヤーがゲームサーバーを兼ねることにより、運営リソースを節約できます。一方で、ユーザーの端末スペックに依存してしまうこと、また多人数のゲームには不向きである点が欠点です。 DedicatedServer:ゲームサーバー上でグラフィックの レンダリング を行わいません。運営側でサーバーを用意する必要があり、ユーザー数の増加に伴いインフラコストもかかりますが、多人数のゲームにも対応可能です。 ゲームセッション よく混同されますが、ゲームセッションと Webサービス のセッションは異なります。 ゲームセッションは、具体的にはゲームサーバー上で動作するゲーム インスタンス を指します。 複数のプレイヤーが同一のゲームセッションに接続することで、「同じゲーム空間の共有 = マルチプレイ 」が可能になります。 オンラインサービス 一般的なオンライン マルチプレイ ゲームでは、UserやSession、AchievementやFriendなどの機能が必要になります。 そこでサービスプラットフォーム(Steam、 Xbox live 、 Facebook など)では、このような機能がオンラインサービスとして提供されております。 サービスプラットフォームを利用せずに自前で構築することももちろん可能です。例えば AWS では GameLift などのサービスも提供しており、DedicatedServerの ホスティング に加えてオンラインサービスの提供もされています。 AWS GameLift の利用方法については、 孫さんの記事 をぜひご覧ください。 OnlineSubsystem Unreal Engine が提供するPluginです。 各オンラインサービスプラットフォームにアクセスする為の共通モジュールおよびインターフェースが提供されています。 Steam、 Xbox live 、 Facebook 、EOSなど マルチプラットフォーム のゲームが、基本的にはコンフィギューレーションを調整するだけで1コードベースで マルチプラットフォーム の実装が可能です。 インターフェースには、Session、Friends, Achievementsなどが提供されています。 本記事では、基本的なCreateSession()とFindSessions()、JoinSession()を用います。 詳細は以下をご覧ください。 https://docs.unrealengine.com/5.1/en-US/API/Plugins/OnlineSubsystem/Interfaces/IOnlineSession/ 実施手順 今回は、検証を簡易にするためListenServer方式で検証を行います。 また、セッション管理を行うオンラインサービスについては、開発用IDが無償提供されているSteamネットワークを使用します。 UEプロジェクト作成と各種設定 Session Interfaceの作成 CreateSession()の実装 JoinGameSession()の実装 端末2台を用いた接続確認 実施環境/ツール OS: Windows 11 pro GPU : NVIDIA GeForce RTX 3070Ti Laptop DCC: Adobe Substance 3D Sampler 3.4.1 Game Engine: Unreal Engine 5.1.0 1. UEプロジェクト作成と各種設定 Unreal Engine のNew Project > Third Personテンプレートを選択します。Project Defaultsで、 C++ を選択します。 今回、プロジェクト名は「OnlineMultiplaySteam」としました。 Edit >Pluginを開きます。 「Online Subsystem Steam」を選択します。 Restartが求められるので再起動します。 次に、 Visual Studio エディタに移ります。 SolusionExplorerで以下のファイルを開きます。 Games > OnlineMultiplaySteam > Source > OnlineMultiplaySteam > OnlineMultiplaySteam.Build.cs PublicDependencyModuleに"OnlineSubsystemSteam", "OnlineSubsystem"を追加します。 11行目を以下に書き換え、Buildします。 PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", "OnlineSubsystemSteam", "OnlineSubsystem" }); 次に、DefaultEngine.iniを修正します。 SolusionExplorerで以下のファイルを開きます。 Games > OnlineMultiplaySteam > Config > DefaultEngine.ini こちらのUEドキュメント を参考に、以下を追記します。 [/Script/Engine.GameEngine] +NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="OnlineSubsystemSteam.SteamNetDriver",DriverClassNameFallback="OnlineSubsystemUtils.IpNetDriver") [OnlineSubsystem] DefaultPlatformService=Steam [OnlineSubsystemSteam] bEnabled=true SteamDevAppId=480 [/Script/OnlineSubsystemSteam.SteamNetDriver] NetConnectionClassName="OnlineSubsystemSteam.SteamNetConnection" 今回使用するSteamDevAppIdは480となっております。これはSteamから開発用に提供されているサンプルゲーム(SpaceWar)のIDです。本番開発で用いる場合は、自身でAppIdを取得する必要がありますのでご注意ください。 最後にプロジェクトファイルを生成します。 Editorを閉じて、File Explorer で「Saved」「Intermidiate」「Binaries」ファイルを削除します。その後、「Generate Visual Studio project files」でプロジェクトファイルを生成します。 これでプロジェクト設定は完了です。 2. Session Interfaceの作成 ThirdPersonTemplateのCharacterクラスを修正します。 Unreal Engine 独自の プレフィックス (クラス名にF,A、型名にF,Uなど)については、 公式のコーディング規約 をご参照ください。 OnlineMultiplaySteamCharacter.hを編集します。 includeに以下を追加します。 #include "Interfaces/OnlineSessionInterface.h" 記載する行について、"....generated.h"が一番最下部になる点にはご注意ください。 class内に、以下を追加します。 public: IOnlineSessionPtr OnlineSessionInterface; SessionInterfaceの変数が宣言できました。 IOnlineSession インターフェイス 仕様は、以下を参照してください。 https://docs.unrealengine.com/5.1/en-US/API/Plugins/OnlineSubsystem/Interfaces/IOnlineSession/ OnlineMultiplaySteamCharacter.cppを編集します。 includeに以下を追加します。 #include "OnlineSubsystem.h" コンスト ラク タの最下部に、以下を追記します。 IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get(); if (OnlineSubsystem)    {     OnlineSessionInterface = OnlineSubsystem->GetSessionInterface();     if (GEngine) { GEngine->AddOnScreenDebugMessage( 15.f, Color::Blue, String::Printf(TEXT("Found subsystem %s"), *OnlineSubsystem->GetSubsystemName().ToString()) ); } } OnlineSubsystemを使って、事前に指定したSteamと接続するためのInterfaceを取得しています。 本処理はCharacterクラスに追記しているため、キャ ラク ターがレベルにSpawnするタイミングでSessionInterfaceが作成されることになります。 IOnlineSubsystemの インターフェイス 仕様は、以下をご確認ください。 https://docs.unrealengine.com/5.1/en-US/API/Plugins/OnlineSubsystem/IOnlineSubsystem/ ビルドしてゲームを立ち上げます。 以下のように左側に青字のメッセージが表示されることを確認します。 SessionInterfaceが無事作成され、Steamネットワークに接続ができました。 3. CreateSession()の実装 作成したSessionInterfaceを使って、ゲームセッションの作成を行います。 事前に、セッション作成後の移動先レベルを作っておきます。レベルはDafaultのままで、名称はLobbyとします。 OnlineMultiplaySteamCharacter.hを編集します。 BluePrintでイベント(今回はKey1押下イベント)を受け取ってセッションを実行する為に、 protectedセクションを作成して以下BlueprintCallable関数を追加します。 protected: UFUNCTION(BlueprintCallable) void CreateGameSession(); OnlineSessionInterfaceでCreateGameSesion()を実行後、コールバック関数であるOnCreateSessionComplete()を実行します。 同じくprotectedセクションに、セッション作成後に呼びだすコールバック関数も追加します。 void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful); privateセクションを追加して、コールバック関数をBindするためのDelegete変数も追加します。 private: FOnCreateSessionCompleteDelegate CreateSessionCompleteDelegate; 続いて、.cppファイルにDelegete変数をコールバック関数とbindした上で、OnlineSessionInterfaceの Delegate ハンドラーに登録していきます。 OnlineMultiplaySteamCharacter.cppに以下を追加します。 コンスト ラク タの冒頭に、以下のように Delegate 変数にコールバック関数をBindします。 AOnlineMultiplaySteamCharacter::AOnlineMultiplaySteamCharacter(): CreateSessionCompleteDelegate(FOnCreateSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnCreateSessionComplete)) CreateGameSession ()を追加します。 少し長いコードになってきたので、折りたたみ表示にします。詳細はコメントをご確認ください。 CreateGameSession() void AOnlineMultiplaySteamCharacter::CreateGameSession() { // called when pressed 1 key if (!OnlineSessionInterface.IsValid()) { return; } // check existing session auto ExistingSession = OnlineSessionInterface->GetNamedSession(NAME_GameSession); if (ExistingSession != nullptr) { OnlineSessionInterface->DestroySession(NAME_GameSession); } // Add Delegete variable to OnlineSessionInterface OnlineSessionInterface->AddOnCreateSessionCompleteDelegate_Handle(CreateSessionCompleteDelegate); // Create Session Settings TSharedPtr <FOnlineSessionSettings> SessionSettings = MakeShareable(new FOnlineSessionSettings()); SessionSettings->bIsLANMatch = false; SessionSettings->NumPublicConnections = 4; SessionSettings->bAllowJoinInProgress = true; SessionSettings->bAllowJoinViaPresence = true; SessionSettings->bShouldAdvertise = true; SessionSettings->bUsesPresence = true; SessionSettings->bUseLobbiesIfAvailable = true; SessionSettings->Set(FName("MatchType"), FString("FreeForAll"), EOnlineDataAdvertisementType::ViaOnlineServiceAndPing); //Create Session const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController(); OnlineSessionInterface->CreateSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, *SessionSettings); } SessionSettingsを用意してSessionInterface->CreateSession関数を呼んでいます。 また、コンスト ラク タでも触れた Delegate 変数をハンドラーに登録しています。 SessionSettingのパラメーターは こちら を参照してください。 セッション作成後に呼びだすコールバック関数を実装します。 OnCreateSessionComplete() void AOnlineMultiplaySteamCharacter::OnCreateSessionComplete(FName SessionName, bool bWasSuccessful) { if (bWasSuccessful) { if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, 15.f, FColor::Blue, FString::Printf(TEXT("Successsfully Created session: %s"), *SessionName.ToString()) ); } UWorld* World = GetWorld(); if (World) { World->ServerTravel(FString("/Game/ThirdPerson/Maps/Lobby?Listen")); } } else { if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, 15.f, FColor::Red, FString(TEXT("Failed to create session!")) ); } } セッション作成完了後にシンプルなログメッセージを表示しています。 またLobbyレベルへの移動は、World->ServerTravel()関数を用いました。 オプションパラメーターでListenサーバーを指定しています。 イベントを受け取るために、EditorでThirdPersonCharacterのBPを開いて以下ノードを追加します。 これで、Key1を押下すると、BluePrint Callableで実装したCreate Game Session関数が実行されます。 コンパイル 後、ゲームを立ち上げます。 Key1を押下します。 成功ログが画面に表示されれば、セッション作成は完了です。 これで、ListenServer方式のゲームサーバー側の処理は完了しました。 注意:オンラインシステムへの接続を試す際、ゲームをEditor Viewportで実行しても正しく接続されません。Standsloneモード or パッケージ化した上で実行してください。 4. JoinGameSession()の実装 BluePrintでイベント(こちらはKey2押下イベント)を受け取ってセッションに参加する処理を実装します。 OnlineMultiplaySteamCharacter.hを編集します。 protectedセクションを作成して以下BlueprintCallable関数を追加します。 protected: UFUNCTION(BlueprintCallable) void JoinGameSession(); このJoinGameSesion()の中では、「セッションの検索」と「セッションへの参加」の2つの処理を行います。 その為、今回はそれぞれのコールバック関数を2つ用意します。 void OnFindSessionsComplete(bool bWasSuccessful); void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result); privateセクションに、Delegete変数も2つ追加します。 また、検索条件を格納する為のSharedPointerも追加します。 FOnFindSessionsCompleteDelegate FindSessionsCompleteDelegate; FOnJoinSessionCompleteDelegate JoinSessionCompleteDelegate; // Search Setting TSharedPtr<FOnlineSessionSearch> SessionSearch; 続いてOnlineMultiplaySteamCharacter.cppを編集します。 includeに以下を追加します。 #include "OnlineSessionSettings.h" 3.と同様にコンスト ラク タにて、 Delegate 変数にコールバック関数をbindします。 FindSessionsCompleteDelegate(FOnFindSessionsCompleteDelegate::CreateUObject(this, &ThisClass::OnFindSessionsComplete)), JoinSessionCompleteDelegate(FOnJoinSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnJoinSessionComplete)) 以下関数を実装します。 void JoinGameSession(); void OnFindSessionsComplete(); void OnJoinSessionComplete(); こちらもそれぞれ折りたたみ表示にします。詳細はコメントをご確認ください。 まずJoinGameSession()では、検索条件であるSessionSearchを設定してFindSessionsを実行します。 SessionSearchのパラメーターは こちら をご覧ください。 JoinGameSession() void AOnlineMultiplaySteamCharacter::JoinGameSession() { // called when pressing 2 key if (!OnlineSessionInterface.IsValid()) { return; } if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, 15.f, FColor::Blue, FString::Printf(TEXT("pressed 2 and Executed function: JoinGameSession()")) ); } OnlineSessionInterface->AddOnFindSessionsCompleteDelegate_Handle(FindSessionsCompleteDelegate); SessionSearch = MakeShareable(new FOnlineSessionSearch()); SessionSearch->MaxSearchResults = 10000; SessionSearch->bIsLanQuery = false; SessionSearch->QuerySettings.Set(SEARCH_PRESENCE, true, EOnlineComparisonOp::Equals); const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController(); OnlineSessionInterface->FindSessions(*LocalPlayer->GetPreferredUniqueNetId(), SessionSearch.ToSharedRef()); } OnFindSessionsComplete()は、FindSessions()実行後のコールバック関数です。 検索結果のSession情報を用いて、セッションに参加します。 今回利用しているSteamAppIDは世界中の人が使っている為、SessionSettingで定義したMatchTypeにフィルタリングをしています。取得したセッション情報を引数に、JoinSession()を実行します。 OnFindSessionsComplete() void AOnlineMultiplaySteamCharacter::OnFindSessionsComplete(bool bWasSuccessful) { if (!OnlineSessionInterface.IsValid()) { return; } if (bWasSuccessful) { GEngine->AddOnScreenDebugMessage(-1,15.f,FColor::Cyan, FString::Printf(TEXT("FindSession Complete! SearchResults.Num() = %d"), SessionSearch->SearchResults.Num())); for (auto Result : SessionSearch->SearchResults) { FString Id = Result.GetSessionIdStr(); FString User = Result.Session.OwningUserName; FString MatchType; Result.Session.SessionSettings.Get(FName("MatchType"), MatchType); if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, 15.f, FColor::Cyan, FString::Printf(TEXT("Successsfully Find Session! Id: %s , OwningUser: %s"), *Id, *User) ); } if (MatchType == FString("FreeForAll")) { if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, 15.f, FColor::Cyan, FString::Printf(TEXT("Joining Match Type: %s"), *MatchType) ); } OnlineSessionInterface->AddOnJoinSessionCompleteDelegate_Handle(JoinSessionCompleteDelegate); const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController(); OnlineSessionInterface->JoinSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, Result); } } } else { if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, 15.f, FColor::Red, FString(TEXT("Failed to join session!")) ); } } } OnJoinSessionComplete()では、セッションへの参加後、セッション情報から IPアドレス を取得してレベルに移動します。 レベルの移動には、PlayerController->ClientTravel()を用います。 OnJoinSessionComplete() void AOnlineMultiplaySteamCharacter::OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result) { if (!OnlineSessionInterface.IsValid()) { return; } FString Address; if (OnlineSessionInterface->GetResolvedConnectString(NAME_GameSession, Address)) { if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, 15.f, FColor::Yellow, FString::Printf(TEXT("Connect string: %s"), *Address) ); } APlayerController* PlayerController = GetGameInstance()->GetFirstLocalPlayerController(); if (PlayerController) { PlayerController->ClientTravel(Address, ETravelType::TRAVEL_Absolute); } } } 最後に、EditorでThirdPersonCharacterのBPを開いて以下ノードを追加します。 5. 端末2台を用いた接続確認 マルチプレイ の検証のために、 Windows マシンを2つ用意します。 また、Steam IDも2つ用意します(Steam IDが同一だとセッションに入れない為)。 各Steamアカウントの設定で、Download Regionを同一にする必要がある点にもご注意ください。 1台目で、Key1を押下します。 2台目で、Key2を押下します。 操作をしてみて、動きが正しく同期されていることを確認します。 無事、オンライン経由で マルチプレイ を実現しました。 所感 今回は、OnlineSubSystemを用いて、インターネット経由のオンライン マルチプレイ を実装しました。 基本的には Unreal Engine で提供されているOnlineSubsystem Pluginを用いることで実現可能なため、単純な ユースケース であれば比較的実装はしやすい印象でした。 以前紹介したPixelStreaming と組み合わせることで、今回用意したような高スペックなマシン不要で、オンライン マルチプレイ を実現できます。 これらについては今後も検証していきたいと思います。 以前の記事 でも紹介したように、現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ご連絡ください。 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ 参考 https://docs.unrealengine.com/5.1/en-US/online-subsystem-steam-interface-in-unreal-engine/ 執筆: @yamashita.yuki 、レビュー: @wakamoto.ryosuke ( Shodo で執筆されました )
みなさんこんにちは! 金融ソリューション事業部リースソリューション部の寺山です。本年から所属部署が変わりました。 今回は業務中に発見した Terraform の Tips を紹介する短めの記事となります。 先に結論 Terraform CloudのRemoteモードについて Remoteモード時のローカルplan時間の課題 解決方法と私がハマったポイント 注意点 先に結論 タイトルを先に回収しますと、ローカル plan 時間が大幅に改善された .terraformignore は以下です。 # ignore all ** # include terraform !code/terraform/ # ignore .terraform code/terraform/**/.terraform/ 以降のセクションでは背景等を含めて詳細を解説します。 Terraform CloudのRemoteモードについて 弊社の金融ソリューション事業部は M5 という Java 製マイクロサービス フレームワーク を製品開発し、お客様のシステムや自社サービス/パッケージの開発に活用しております。私は、この M5 をホストするためのインフラ構成の検討や、その実装方法(主に IaC の開発)に従事しています。 IaC 化ツールとして HashiCorp 社の Terraform 、および、同社の SaaS である Terraform Cloud を利用しています。 Terraform Cloud の Workspace の設定には Execution Mode というものがあります。この設定で Remote を選択すると、 terraform plan や terraform apply といった Terraform の実行は Terraform Cloud でホストされる 仮想マシン 内で実行されます。 Remote モードには以下のようなメリットがあります。 GitHub 等の構成管理サービスと組み合わせて、インフラスト ラク チャに対する CI/CD パイプラインを容易に構築可能 インフラスト ラク チャの操作権限は Terraform Cloud に設定すればよいため、 AWS の IAM ユーザアクセスキーのような認証情報を作業者全員に配布することは不要となる インフラスト ラク チャに影響を及ぼす terraform apply も Terraform Cloud 上で実行されるため、操作証跡が集約される Terraform および Provider Plugin のバージョンの作業者間における差異を抑制できる 専用の UI が提供されており、特に plan や apply コマンドの結果の視認性が優れている また、Remote モードではローカルで実行した terraform plan も Terraform Cloud 上で実行されます。タイトルの「ローカル plan」とはこのオペレーションのことを指しています。 Remoteモード時のローカルplan時間の課題 前述のようにメリットが多い Terraform Cloud の Remote モードですが、実は課題に感じている点がありました。 ローカルで plan 時は、ローカルの ファイルシステム にある リポジトリ ルート ディレクト リを起点として全ファイルが Terraform Cloud に送信されます。送信された ソースコード を基に環境が構築され、 plan が実行される仕組みです。 このローカル plan を実行には、毎回 10 分ほど要していました。 $ time terraform plan # (省略) terraform plan 94.39s user 8.39s system 18% cpu 9:21.40 total plan は頻繁に実行するコマンドであるため、毎回 10 分待たされるのは生産性に影響が出てしまいます。 Remote モードではなく Local に変更するか、Terraform Cloud の利用を停止するかを検討していました。 解決方法と私がハマったポイント 本件の解決方法は冒頭のセクションに記載したとおり .terraformignore ファイルを利用することです。 .terraformignore は .gitignore に類似した仕組みで、当該ファイルに記述したファイルや ディレクト リを Terraform Cloud に送信する対象外とします。 このファイルの仕様は Excluding Files from Upload with .terraformignore で説明されています。 .gitignore に類似した仕組みではあるものの リポジトリ のルート ディレクト リ以外では機能しない デフォルトで .git と .terraform ディレクト リが除外される 実は .terraformignore のことは以前から認識しており利用していました。当該 リポジトリ は monorepo として運用しており、Terraform の IaC 化コードの他に node_modules のような容量が大きい ディレクト リも存在していました。 Terraform の ディレクト リ以外を .terraformignore に記述することでローカル plan 時間が改善されることを期待していたのですが、前述した plan 時間は .terraformignore 配置後のものでした。 この頃に指定していた内容は以下です。 # ignore all ** # include terraform !code/terraform/ 1 行目の記述で リポジトリ 内の全ファイルを除外した上で、Terraform の IaC 化コードが配置された ディレクト リのみは Terraform Cloud に送信されるように ! を付与しています。 前述したように monorepo 運用している背景から、今後も ディレクト リが増えたとしても、このファイルをメンテナンスする必要がないよう ホワイトリスト 形式にしたいという意図です。 TF_IGNORE 環境変数 に trace を指定し、除外されたファイルを表示して確認すると以下のような出力となります。 $ terraform plan #(省略) Skipping excluded path: .git Skipping excluded path: .git/COMMIT_EDITMSG Skipping excluded path: .git/FETCH_HEAD #(省略) Skipping excluded path: code/shell/aws/sts/set-sts-profile.sh Skipping excluded path: code/terraform Skipping excluded path: doc #(省略) Skipping excluded path: doc/node_modules Skipping excluded path: doc/node_modules/.bin Skipping excluded path: doc/node_modules/.bin/acorn #(省略) 一見、記述の意図したとおり除外できているように見えます。 しかしながら、デフォルトで除外されている .terraform は Terraform Cloud に送信されてしまっていたため、ローカル plan 時間が長くなっていたとわかりました。 .terraform ディレクト リには terraform init 時に Provider Plugin がダウンロードされます。この プラグイン が数百 MB の容量のため、送信に時間を要します。 $ du -h .terraform 369M .terraform/providers/registry.terraform.io/hashicorp/aws/4.52.0/darwin_amd64 369M .terraform/providers/registry.terraform.io/hashicorp/aws/4.52.0 369M .terraform/providers/registry.terraform.io/hashicorp/aws 14M .terraform/providers/registry.terraform.io/hashicorp/random/3.3.2/darwin_amd64 14M .terraform/providers/registry.terraform.io/hashicorp/random/3.3.2 13M .terraform/providers/registry.terraform.io/hashicorp/random/3.4.3/darwin_amd64 13M .terraform/providers/registry.terraform.io/hashicorp/random/3.4.3 28M .terraform/providers/registry.terraform.io/hashicorp/random 397M .terraform/providers/registry.terraform.io/hashicorp 397M .terraform/providers/registry.terraform.io 397M .terraform/providers 4.0K .terraform/modules 397M .terraform したがって、私のように ホワイトリスト 風な記述をする場合は、 .terraform ディレクト リを明示的に指定する必要があります。 # ignore all ** # include terraform !code/terraform/ # ignore .terraform code/terraform/**/.terraform/ ##←これ .terraformignore に追加後はローカル plan 時間が 1 分ほどとなり、大幅に短縮されました! $ time terraform plan # (省略) Skipping excluded path: code/shell/aws/sts/set-sts-profile.sh Skipping excluded path: code/terraform Skipping excluded path: code/terraform/aws/excluded/.terraform/ Skipping excluded path: code/terraform/aws/excluded/.terraform/environment Skipping excluded path: code/terraform/aws/excluded/.terraform/providers Skipping excluded path: code/terraform/aws/excluded/.terraform/providers/registry.terraform.io Skipping excluded path: code/terraform/aws/excluded/.terraform/providers/registry.terraform.io/hashicorp Skipping excluded path: code/terraform/aws/excluded/.terraform/providers/registry.terraform.io/hashicorp/aws Skipping excluded path: code/terraform/aws/excluded/.terraform/providers/registry.terraform.io/hashicorp/aws/4.52.0 Skipping excluded path: code/terraform/aws/excluded/.terraform/providers/registry.terraform.io/hashicorp/aws/4.52.0/darwin_amd64 Skipping excluded path: code/terraform/aws/excluded/.terraform/providers/registry.terraform.io/hashicorp/aws/4.52.0/darwin_amd64/terraform-provider-aws_v4.52.0_x5 # (省略) terraform plan 6.07s user 0.58s system 11% cpu 58.623 total 1 分ほどであれば生産性への影響は許容範囲であると評価しており、Terraform Cloud の Remote モードの利用を継続することにネガティブな要素はなくなりました。 なお、 ブラックリスト 形式で除外したいパスを丁寧に記述すれば、Terraform のドキュメントに記載のとおりデフォルトで .git と .terraform ディレクト リは除外されます。 同じような境遇の方のご参考になれば幸いです! 最後までお読みいただきありがとうとございました。 注意点 ローカル plan の所要時間は、Terraform の IaC 化コードのファイルサイズやネットワーク帯域・通信品質に依存するため、同じ効果が得られるとはお約束できないこと、ご留意ください。 私たちは一緒に働いてくれる仲間を募集しています! 金融機関のようなお客様においても クラウド ファーストやマイクロサービスのようなモダンな技術の採用が進みつつあります。ご自身のインフラ/ クラウド スキルを エンタープライズ 領域でも活用したい・挑戦したいという方がいらっしゃいましたら、ぜひご応募ください! 金融システム インフラ・アーキテクト 金融システム インフラエンジニア 執筆: 寺山 輝 (@terayama.akira) 、レビュー: @mizuno.kazuhiro ( Shodo で執筆されました )
みなさんこんにちは! 金融ソリューション事業部リースソリューション部の寺山です。本年から所属部署が変わりました。 今回は業務中に発見した Terraform の Tips を紹介する短めの記事となります。 先に結論 Terraform CloudのRemoteモードについて Remoteモード時のローカルplan時間の課題 解決方法と私がハマったポイント 注意点 先に結論 タイトルを先に回収しますと、ローカル plan 時間が大幅に改善された .terraformignore は以下です。 # ignore all ** # include terraform !code/terraform/ # ignore .terraform code/terraform/**/.terraform/ 以降のセクションでは背景等を含めて詳細を解説します。 Terraform CloudのRemoteモードについて 弊社の金融ソリューション事業部は M5 という Java 製マイクロサービス フレームワーク を製品開発し、お客様のシステムや自社サービス/パッケージの開発に活用しております。私は、この M5 をホストするためのインフラ構成の検討や、その実装方法(主に IaC の開発)に従事しています。 IaC 化ツールとして HashiCorp 社の Terraform 、および、同社の SaaS である Terraform Cloud を利用しています。 Terraform Cloud の Workspace の設定には Execution Mode というものがあります。この設定で Remote を選択すると、 terraform plan や terraform apply といった Terraform の実行は Terraform Cloud でホストされる 仮想マシン 内で実行されます。 Remote モードには以下のようなメリットがあります。 GitHub 等の構成管理サービスと組み合わせて、インフラスト ラク チャに対する CI/CD パイプラインを容易に構築可能 インフラスト ラク チャの操作権限は Terraform Cloud に設定すればよいため、 AWS の IAM ユーザアクセスキーのような認証情報を作業者全員に配布することは不要となる インフラスト ラク チャに影響を及ぼす terraform apply も Terraform Cloud 上で実行されるため、操作証跡が集約される Terraform および Provider Plugin のバージョンの作業者間における差異を抑制できる 専用の UI が提供されており、特に plan や apply コマンドの結果の視認性が優れている また、Remote モードではローカルで実行した terraform plan も Terraform Cloud 上で実行されます。タイトルの「ローカル plan」とはこのオペレーションのことを指しています。 Remoteモード時のローカルplan時間の課題 前述のようにメリットが多い Terraform Cloud の Remote モードですが、実は課題に感じている点がありました。 ローカルで plan 時は、ローカルの ファイルシステム にある リポジトリ ルート ディレクト リを起点として全ファイルが Terraform Cloud に送信されます。送信された ソースコード を基に環境が構築され、 plan が実行される仕組みです。 このローカル plan を実行には、毎回 10 分ほど要していました。 $ time terraform plan # (省略) terraform plan 94.39s user 8.39s system 18% cpu 9:21.40 total plan は頻繁に実行するコマンドであるため、毎回 10 分待たされるのは生産性に影響が出てしまいます。 Remote モードではなく Local に変更するか、Terraform Cloud の利用を停止するかを検討していました。 解決方法と私がハマったポイント 本件の解決方法は冒頭のセクションに記載したとおり .terraformignore ファイルを利用することです。 .terraformignore は .gitignore に類似した仕組みで、当該ファイルに記述したファイルや ディレクト リを Terraform Cloud に送信する対象外とします。 このファイルの仕様は Excluding Files from Upload with .terraformignore で説明されています。 .gitignore に類似した仕組みではあるものの リポジトリ のルート ディレクト リ以外では機能しない デフォルトで .git と .terraform ディレクト リが除外される 実は .terraformignore のことは以前から認識しており利用していました。当該 リポジトリ は monorepo として運用しており、Terraform の IaC 化コードの他に node_modules のような容量が大きい ディレクト リも存在していました。 Terraform の ディレクト リ以外を .terraformignore に記述することでローカル plan 時間が改善されることを期待していたのですが、前述した plan 時間は .terraformignore 配置後のものでした。 この頃に指定していた内容は以下です。 # ignore all ** # include terraform !code/terraform/ 1 行目の記述で リポジトリ 内の全ファイルを除外した上で、Terraform の IaC 化コードが配置された ディレクト リのみは Terraform Cloud に送信されるように ! を付与しています。 前述したように monorepo 運用している背景から、今後も ディレクト リが増えたとしても、このファイルをメンテナンスする必要がないよう ホワイトリスト 形式にしたいという意図です。 TF_IGNORE 環境変数 に trace を指定し、除外されたファイルを表示して確認すると以下のような出力となります。 $ terraform plan #(省略) Skipping excluded path: .git Skipping excluded path: .git/COMMIT_EDITMSG Skipping excluded path: .git/FETCH_HEAD #(省略) Skipping excluded path: code/shell/aws/sts/set-sts-profile.sh Skipping excluded path: code/terraform Skipping excluded path: doc #(省略) Skipping excluded path: doc/node_modules Skipping excluded path: doc/node_modules/.bin Skipping excluded path: doc/node_modules/.bin/acorn #(省略) 一見、記述の意図したとおり除外できているように見えます。 しかしながら、デフォルトで除外されている .terraform は Terraform Cloud に送信されてしまっていたため、ローカル plan 時間が長くなっていたとわかりました。 .terraform ディレクト リには terraform init 時に Provider Plugin がダウンロードされます。この プラグイン が数百 MB の容量のため、送信に時間を要します。 $ du -h .terraform 369M .terraform/providers/registry.terraform.io/hashicorp/aws/4.52.0/darwin_amd64 369M .terraform/providers/registry.terraform.io/hashicorp/aws/4.52.0 369M .terraform/providers/registry.terraform.io/hashicorp/aws 14M .terraform/providers/registry.terraform.io/hashicorp/random/3.3.2/darwin_amd64 14M .terraform/providers/registry.terraform.io/hashicorp/random/3.3.2 13M .terraform/providers/registry.terraform.io/hashicorp/random/3.4.3/darwin_amd64 13M .terraform/providers/registry.terraform.io/hashicorp/random/3.4.3 28M .terraform/providers/registry.terraform.io/hashicorp/random 397M .terraform/providers/registry.terraform.io/hashicorp 397M .terraform/providers/registry.terraform.io 397M .terraform/providers 4.0K .terraform/modules 397M .terraform したがって、私のように ホワイトリスト 風な記述をする場合は、 .terraform ディレクト リを明示的に指定する必要があります。 # ignore all ** # include terraform !code/terraform/ # ignore .terraform code/terraform/**/.terraform/ ##←これ .terraformignore に追加後はローカル plan 時間が 1 分ほどとなり、大幅に短縮されました! $ time terraform plan # (省略) Skipping excluded path: code/shell/aws/sts/set-sts-profile.sh Skipping excluded path: code/terraform Skipping excluded path: code/terraform/aws/excluded/.terraform/ Skipping excluded path: code/terraform/aws/excluded/.terraform/environment Skipping excluded path: code/terraform/aws/excluded/.terraform/providers Skipping excluded path: code/terraform/aws/excluded/.terraform/providers/registry.terraform.io Skipping excluded path: code/terraform/aws/excluded/.terraform/providers/registry.terraform.io/hashicorp Skipping excluded path: code/terraform/aws/excluded/.terraform/providers/registry.terraform.io/hashicorp/aws Skipping excluded path: code/terraform/aws/excluded/.terraform/providers/registry.terraform.io/hashicorp/aws/4.52.0 Skipping excluded path: code/terraform/aws/excluded/.terraform/providers/registry.terraform.io/hashicorp/aws/4.52.0/darwin_amd64 Skipping excluded path: code/terraform/aws/excluded/.terraform/providers/registry.terraform.io/hashicorp/aws/4.52.0/darwin_amd64/terraform-provider-aws_v4.52.0_x5 # (省略) terraform plan 6.07s user 0.58s system 11% cpu 58.623 total 1 分ほどであれば生産性への影響は許容範囲であると評価しており、Terraform Cloud の Remote モードの利用を継続することにネガティブな要素はなくなりました。 なお、 ブラックリスト 形式で除外したいパスを丁寧に記述すれば、Terraform のドキュメントに記載のとおりデフォルトで .git と .terraform ディレクト リは除外されます。 同じような境遇の方のご参考になれば幸いです! 最後までお読みいただきありがとうとございました。 注意点 ローカル plan の所要時間は、Terraform の IaC 化コードのファイルサイズやネットワーク帯域・通信品質に依存するため、同じ効果が得られるとはお約束できないこと、ご留意ください。 私たちは一緒に働いてくれる仲間を募集しています! 金融機関のようなお客様においても クラウド ファーストやマイクロサービスのようなモダンな技術の採用が進みつつあります。ご自身のインフラ/ クラウド スキルを エンタープライズ 領域でも活用したい・挑戦したいという方がいらっしゃいましたら、ぜひご応募ください! 金融システム インフラ・アーキテクト 金融システム インフラエンジニア 執筆: 寺山 輝 (@terayama.akira) 、レビュー: @mizuno.kazuhiro ( Shodo で執筆されました )
こんにちは!金融ソリューション事業部の孫です。 今回の記事では、GameLiftを用いたUnrealEngineゲームセッションのマッチング基盤の構築をご紹介します! 実施事項が多い為、Part1~3の3記事に分けて連載します。 また、GameLiftを用いたゲームセッションの作成、接続については 前回の記事 でご紹介しておりますので、そちらもご覧いただければ幸いです。 Part1である今回は、 AWS GameLiftにおいてFlexMatchに関する コンポーネント の構築を行います。 Part2の記事は こちら です! Part3の記事は こちら です! はじめに 冒頭でGameLiftを用いてマッチング基盤を構築することを述べましたが、具体的にはGameLiftが提供するFlexMatch機能を利用します。 FlexMatch機能 とは、カスタマイズ可能なマッチメイキングサービスです。 特徴として、下記が挙げられます。 簡単にゲームにあったカスタムルールを複数作れる サーバーとの間の レイテンシー を基準にマッチング可能 時間と共にルールの条件を緩和することも可能 ユーザーによるマッチング結果の承諾機能 キューを使用してゲームセッションを効率的に配置 FlexMatchを使用することで、フレキシブルなマッチング処理を簡単に実現でき、 マルチプレイヤー ゲームをさらに楽しむことができます! 今回の検証では、GameLift以外他の AWS コンポーネント (Cognito、AmazonSNS、 Dynamo 、 API Gateway 、Lambda)も利用しています。説明をシンプルにする為、それらの コンポーネント の解説は割愛いたします。 マッチング基盤の構築手順 手順は、以下のとおりです。 【Part1】 1.ゲームセッション保管用キューの作成 2.マッチングイベント発行用 Amazon SNS の作成 3.マッチングイベント保管用 Amazon DynamoDB の作成 4.FlexMatchの構築 【Part2】 5.プレイヤー管理用 Amazon Cognitoの作成 6.マッチング処理用のバックエンド API の作成 【Part3】 7.UEクライアントへの API 組み込み 8.マッチング機能の検証 AWS の アーキテクチャ 構築全体像は、以下の アーキテクチャ 図をご参照ください。 今回はGameLiftが担当するゲーム用の クラウド サーバーの管理以外にも、以下の機能を実装します。 プレイヤーアカウントの作成、および認証認可管理(Cognito) マッチングをコン トロール するバックエンドサービス(Lambda関数、 SNS ) マッチング機能をクライアントから呼びだす API ( API Gateway ) マッチングイベントとプレイヤーデータの一時保管(DynamoDB) プレイヤーはまず Cognito 認証でアクセスキーを取得し、次にアクセスキーを用いてマッチング API にリク エス トし、最後にマッチング結果を取得してマッチングを完了します。 マッチングは現在オンライン中のプレイヤーを対象に、事前に設定したルールセットに従って処理を行います。 使用環境/ツール Unreal Engine 5.1.0 Windows10 21H2 x64 RAM 64GM, SSD 1TB NVIDIA GeForce GTX 3080Ti Gamelift SDK GameLift Cpp ServerSDK 3.4.2 GameLift Unreal plugin 3.4.0 Visual Studio 2019 version 16.11.21(以下VS2019) JavaSDK 1.8.0 ゲームセッション保管用キューの作成 FlexMatchを使ってプレイヤーマッチングを行うにあたり、一時的なゲームセッションを保管するキューを作成することで、Fleet IQサービスの最大活用およびマッチング遅延の改善を図ることができます。 GameLift ダッシュ ボードページで、「 ダッシュ ボード」⇒「キュー作成する」をクリックしてキュー作成ページを開きます キュー名を入れた上、「キューの作成」ボタンを押して、キューの作成を完了します ※今回は検証のため、その他設定はデフォルト値のままで十分です ※キューの作成にあたり、事前にFleetの作成が必要です。具体的な手順は 前回の記事 をご参照ください キュー名:「GameLift_PoC_Queue」としました マッチングイベント発行用 Amazon SNS の作成 AmazonSNS( Amazon Simple Notification Serviceの省略)の機能を利用することで、マッチング中にGameLiftが出力したイベントを非同期で拾ってくれます。 イベントによって現在マッチングのステータスを把握した上で、 ネクス トアクションを決めます。 イベントの種類については、以下が挙げられます。 MatchmakingSearching(マッチング中) PotentialMatchCreated(マッチング候補が作成済「プレイヤーの承諾前」) AcceptMatch(マッチング候補を承諾済) AcceptMatchCompleted(プレイヤーの承諾・却下または承諾の タイムアウト により、マッチングの承諾プロセス完了) MatchMakingSucceded(マッチングが正常に完了し、ゲームセッションが作成済) MatchMakingTimedOut( タイムアウト によってマッチング失敗) MatchMakingCancelled(マッチングがキャンセル済) MatchMakingFailed(マッチングでエラーが発生) ※Tips:マッチング処理をよりよくスムーズに実装するため、各イベントの返却文 参照資料 を覚えておくことをお勧めします 。 これから、AmazonSNSを作成します。 AWS マネジメントコンソールの検索窓口に「 SNS 」を入力して、 SNS のトップページに移動します 左側Menu「Topic」⇒「Topicの作成」をクリックして、Topicの作成画面が表示されます 以下の内容を入力して、「Topicの作成」ボタンを押して、 SNS を作成します 名前:「FlexMatchEventNofications」としました アクセスポリシー - オプション:アドバンストを選択し、以下の JSON 文を文末に入力します。 { " Sid ": " __console_pub_0 ", " Effect ": " Allow ", " Principal ": { " Service ": " gamelift.amazonaws.com " } , " Action ": " SNS:Publish ", " Resource ": " arn:aws:sns:your_region:your_account:your_topic_name " , } マッチングイベント保管用 Amazon DynamoDB の作成 今回の検証では、DynamoDBの役割として二つがあります AmazonSNSによって転送されたGameLiftのイベントを保管する オンライン中のプレイヤーIDやそのほかの必要な情報を一時的に預ける これから、DynamoDBを作成します。 AWS マネジメントコンソールの検索窓口に「DynamoDB」を入力して、DynamoDBのトップページに移動します 「テーブルの作成」をクリックして、イベント保管テーブルを作成します ※DynamoDBはNoSQLなので、データベースの作成がなくテーブルから作成します。 テーブル名:「MatchmakingTickets」としました パーティション キー:「Id」としました そのほかの設定:デフォルト値のまま 上記と同様な手順で、オンライン中のプレイヤーデータを保管するテーブルを作成します テーブル名:「Players」としました パーティション キー:「Id」としました そのほかの設定:デフォルト値のまま 作成したテーブルのOverviewページを開いて「Table details」⇒ 「Manage TTL 」をクリックして、 TTL Attributeを追加します。 ※DynamoDBに保存したデータは、基本は一時的なデータなので、 TTL を設定することによって自動的に消してもらうことを図ります TTL Attribute名:「 ttl 」としました ここまで、GameLiftのイベント保管テーブルを作成しました。 これから、AmazonSNSと処理連動するLambda関数を作成します。 ※Lambda関数の動作としては、AmazonSNSから受け取ったイベント情報をDynamoDBに保存します。 AWS マネジメントコンソールでLambdaのトップページに移動します 「関数の作成」ボタンをクリックして作成ページを開きます 以下の内容で関数を作成します 関数名:「TrackEvents」としました ランタイム:「NodeJs 18.x」としました 関数コードについて、以下の ソースコード を入力します 関数の役割としては、GameLiftが発行されたイベントを受け取って中身(イベント番号、イベント種類、ゲームセッション、プレイヤー情報)をDynamoDBに保存する TrackEventsコード const AWS = require(' aws - sdk '); const DynamoDb = new AWS .DynamoDB({region: 'ap-northeast-1'}); exports.handler = async (event) => { let message; let response; if (event.Records && event.Records.length > 0) { const record = event.Records[0]; if (record. Sns && record. Sns .Message) { console.log('message from gamelift: ' + record. Sns .Message); message = JSON .parse(record. Sns .Message); } } if (!message || message['detail-type'] != 'GameLift Matchmaking Event') { response = { statusCode: 400, body: JSON .stringify({ error: 'no message available or message is not about gamelift matchmaking' }) }; return response; } const messageDetail = message.detail; const dynamoDbRequestParams = { RequestItems: { MatchmakingTickets: [] } }; if (!messageDetail.tickets || messageDetail.tickets.length == 0) { response = { statusCode: 400, body: JSON .stringify({ error: 'no tickets found' }) }; return response; } if (messageDetail.type == 'MatchmakingSucceeded' || messageDetail.type == 'MatchmakingTimedOut' || messageDetail.type == 'MatchmakingCancelled' || messageDetail.type == 'MatchmakingFailed') { for (const ticket of messageDetail.tickets) { const ticketItem = {}; ticketItem.Id = {S: ticket.ticketId}; ticketItem.Type = {S: messageDetail.type}; ticketItem. ttl = {N: (Math.floor(Date.now() / 1000) + 3600).toString()}; if (messageDetail.type == 'MatchmakingSucceeded') { ticketItem.Players = {L: []}; const players = ticket.players; for (const player of players) { const playerItem = {M: {}}; playerItem.M.PlayerId = {S: player.playerId}; if (player.playerSessionId) { playerItem.M.PlayerSessionId = {S: player.playerSessionId}; } ticketItem.Players.L.push(playerItem); } ticketItem.GameSessionInfo = { M: { IpAddress: {S: messageDetail.gameSessionInfo.ipAddress}, Port: {N: messageDetail.gameSessionInfo.port.toString()} } }; } dynamoDbRequestParams.RequestItems.MatchmakingTickets.push({ PutRequest: { Item: ticketItem } }); } } await DynamoDb.batchWriteItem(dynamoDbRequestParams) .promise().then(data => { response = { statusCode: 200, body: JSON .stringify({ success: 'ticket data has been saved to dynamodb' }) }; }) .catch(err => { response = { statusCode: 400, body: JSON .stringify({ error: err }) }; }); return response; }; DynamoDBへのアクセスが必要なため、アクセス権限に追加します 「設定」⇒「アクセス権限」で、対象ロールをクリックしてアクセス権限編集ページにとばされます JSON タブの配下に、以下の JSON 文を文末に追記します { " Effect ": " Allow ", " Action ": " dynamodb : BatchWriteItem " " Resource ": " 「関数のARN入力」 " } ここまで、DynamoDBのテーブルとイベント登録用のLambdaを作成しました。 FlexMatchの構築 FlexMatch構築用のリソースを全部そろっているため、これから、FlexMatchルールセットの設定およびMatchmakingの設定を行います。 Matchmaking のルールセットを設定します GameLiftの ダッシュ ボードに移動し「マッチメーキングルールセットの作成」をクリックしてマッチングルール設定ページに移動します ルールセットの設定ページで以下の内容を入力します ルールセット名:「PocRuleSet」としました ルールセット: プレイヤーのチームを2つ作成します 各チームに1名のプレイヤーを含めます 各プレイヤーには所属グループ(groupid)という属性があります 最終的に両チームのプレイヤー数、かつgroupidは同じにする必要があります ルールセット内容 { "name": "poc_test", "ruleLanguageVersion": "1.0", "playerAttributes": [{ "name": "groupid", "type": "number", "default": 1 }], "teams": [{ "name": "play1", "maxPlayers": 1, "minPlayers": 1 }, { "name": "play2", "maxPlayers": 1, "minPlayers": 1 }], "rules": [{ "name": "EqualGroupId", "description": "Only launch a game when the group id of players in each team matches", "type": "comparison", "measurements": [ "teams[play1].attributes[groupid])" ], "referenceValue": "teams[play2].attributes[groupid])", "operation": "=" },{ "name": "EqualTeamSizes", "description": "Only launch a game when the number of players in each team matches", "type": "comparison", "measurements": [ "count(teams[play1].players)" ], "referenceValue": "count(teams[play2].players)", "operation": "=" }] } 「ルールセットの作成」をクリックして作成します Matchmaking Configurationを設定します GameLiftの ダッシュ ボードで「マッチメーキング設定の作成」をクリックしてマッチメーキングの設定ページに移動します 以下の内容でマッチメーキングの設定を行います 名前: 「GameLiftPOCMarchmaker」としました キュー: ap-northeast-1、GameLift_PoC_Queue(手順1で作成したキュー名) リク エス トの タイムアウト :60 ルールセット名: PocRuleSet(前手順で作成したルールセット) 通知先:手順2で作成したFlexMatchEventNoficationsのARN番号 そのほか:デフォルト値 Part1 FlexMatchに関する コンポーネント の構築は、以上となります! Part2の記事は こちら です! Part3の記事は こちら です! 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 執筆: @chen.sun 、レビュー: @wakamoto.ryosuke ( Shodo で執筆されました )
こんにちは!金融ソリューション事業部の孫です。 Part1の記事 では、FlexMatchに関する コンポーネント の構築をご紹介しました! さて、Part2である今回は、 プレイヤーの認証・管理用Cognitoの作成 UEクライアントの組込みに関するバックエンド API の作成 をご紹介します! Part2の続きとして、Part3の記事は こちら です! プレイヤー管理用 Amazon Cognitoの作成 マッチング検証にあたり、複数プレイヤーの登録が必要になります。 その為、プレイヤーの認証、承認および管理をシンプルにする為、 Amazon Cognitoを採用します。 AWS マネジメントコンソールでCognitoのトップページに移動します 左側Menu「ユーザープール」⇒「ユーザープールの作成」ボタンをクリックして作成ページを開きます Cognitoの設定は、以下の内容を入力します Pool Name: 「GameLiftUnreal-UserPool」とします 「Review Defauls」をクリックし、設定を確認した上で、プールを作成します ※Tips: ここまでの設定はプール作成後、変更できないため、ちゃんと確認してから前に進めてください プールの作成完了後、左Menuに「App Clients」⇒ 「Add an app client」をクリックして以下の内容でAPPクライアントを作成します App client name:「GameLiftUnreal-LambdaLoginClient」としました 「Generate client secret」のチェックを外します 「Auth Flows Configuration」: 「Enable username password based authentication」と「Enable refresh token based authentication」のみチェックします そのほか:デフォルト値 左menu「App Integration」⇒「App Client Settings」をクリックし設定を行います Cognito User Pool: チェック入れます Sign in and sign out URLs -> CallBack URL(s): https://aws.amazon.com Sign in and sign out URLs -> Sign out URL(s): https://aws.amazon.com 左menu「App Integration」⇒「Domain name」のDomain Prefix: 今回は「gameliftunreal-cog-20230222」 としました ここまで、Cognitoの構築は完了しました。 次に、UEクライアントとCognito連携用の API を構築します。 AWS マネジメントコンソールでLambdaのトップページに移動します 「関数の作成」ボタンをクリックして、関数作成ページを開きます 以下の内容で関数を作成します。 関数名:「GameLiftUnreal-CognitoLogin」としました ランタイム:「Python3.9」としました 関数コードは、以下の内容を入力します 関数の役割としては、プレイヤーが入力したユーザー名とパスワードをCognitoに送信し、その認証結果をプレイヤーに返却します GameLiftUnreal-CognitoLoginコード import boto3 import os import sys USER_POOL_APP_CLIENT_ID = 'Cognitoで生成したクライアントアプリへのアクセスキー' client = boto3.client('cognito-idp') def lambda_handler(event, context): if 'username' not in event or 'password' not in event: return { 'status': 'fail', 'msg': 'Username and password are required' } resp, msg = initiate_auth(event['username'], event['password']) if msg != None: return { 'status': 'fail', 'msg': msg } return { 'status': 'success', 'tokens': resp['AuthenticationResult'] } def initiate_auth(username, password): try: resp = client.initiate_auth( ClientId=USER_POOL_APP_CLIENT_ID, AuthFlow='USER_PASSWORD_AUTH', AuthParameters={ 'USERNAME': username, 'PASSWORD': password }) except client.exceptions.InvalidParameterException as e: return None, "Username and password must not be empty" except (client.exceptions.NotAuthorizedException, client.exceptions.UserNotFoundException) as e: return None, "Username or password is incorrect" except Exception as e: print("Uncaught exception:", e, file=sys.stderr) return None, "Unknown error" return resp, None Cognitoへのアクセスが必要なため、アクセス権限を追加します 「設定」⇒「アクセス権限」で、対象ロールをクリックしてアクセス権限編集ページにとばされます JSON タブの配下に、以下の JSON 文を文末に追記します { " Sid ": " VisualEditor0 ", " Effect ": " Allow ", " Action ": " cognito-idp:InitiateAuth ", " Resource ": " * " } ここまでは、GameLiftUnreal-CognitoLogin関数を作成しました。 次に、 API Gateway において、関数を外から呼びだす API を作成します。 AWS マネジメントコンソールの検索窓口に「 API Gateway 」を入力して API Gateway のトップページに移動します RestAPIを選択し、「構築」ボタンを押して API の作成ページを開きます 以下の内容で API を作成します プロトコル を選択する:Rest 新しい API の作成 : New API 名前と説明 API 名 : 「GameLiftUnreal- API 」としました その他:デフォルト値 「 API の作成」ボタンを押して API を作成しました。 作成した API を開いて「アクション」⇒「リソースの作成」でloginリソースを作成します 「アクション」⇒「メソッドの作成」でPOSTメソッドを作成します 統合タイプ:lambda Functionチェック Lambdaリージョン:ap-northeast-1 Lambda 関数:前手順で作成したGameLiftUnreal-CognitoLoginを選択 プレイヤーのIDとパスワードを入力した上で、ログイン API にリク エス トしたら、Cognitoからアクセスキーを発行してくれます。 そして、発行されたアクセスキーを用いて、他の API にも正常にリク エス トができます。 マッチング処理用のバックエンド API の作成 上記AmazonSNS作成時に説明した通り、各マッチングイベントごとに後続処理の実装が必要なのですが、今回は検証をシンプルにする目的で「StartMatchMaking」イベントのみ処理します。 また、その他の補助処理として「①GetPlayerData:プレイヤーデータ取得」と「②PollMatchMaking:マッチング結果取得」も実装します。 バックエンドのLambda関数を作成します 上記のLambda作成と同様な手順でLambda作成ページに移動し、以下の内容で関数本体を作成します StartMatchmaking 関数の役割としては、マッチングで必要なデータをインプットしてマッチングを開始します 関数名:「StartMatchmaking」としました ランタイム:「NodeJs 18.x」としました GetPlayerData 関数の役割としては、現在ログイン中のプレイヤー情報を取得します 関数名:「GetPlayerData」としました ランタイム:「NodeJs 18.x」としました PollMatchMakingのLambda関数定義 関数の役割としては、マッチング結果を取得します(ポーリング方式) 関数名:「PollMatchMaking」としました ランタイム:「NodeJs 18.x」としました 上記作成した関数本体を開いて、対象の ソースコード を入力します StartMatchmakingコード const AWS = require(' aws - sdk '); const Lambda = new AWS .Lambda({region: 'ap-northeast-1'}); const GameLift = new AWS .GameLift({region: 'ap-northeast-1'}); exports.handler = async (event) => { let response; let raisedError; let latencyMap; if (event.body) { const body = JSON .parse(event.body); if (body.latencyMap) { latencyMap = body.latencyMap; } } if (!latencyMap) { response = { statusCode: 400, body: JSON .stringify({ error: 'incoming request did not have a latency map' }) }; return response; } const lambdaRequestParams = { FunctionName: 'GetPlayerData', Payload: JSON .stringify(event) }; let playerData; await Lambda. invoke (lambdaRequestParams) .promise().then(data => { if (data && data.Payload) { const payload = JSON .parse(data.Payload); if (payload.body) { const payloadBody = JSON .parse(payload.body); playerData = payloadBody.playerData; } } }) .catch(err => { raisedError = err; }); if (raisedError) { response = { statusCode: 400, body: JSON .stringify({ error: raisedError }) }; return response; } else if (!playerData) { response = { statusCode: 400, body: JSON .stringify({ error: 'unable to retrieve player data' }) }; return response; } const playerId = playerData.Id.S; const groupId = parseInt(playerData.groupId.N, 10); const gameLiftRequestParams = { ConfigurationName: 'GameLiftTutorialMatchmaker', Players: [{ LatencyInMs: latencyMap, PlayerId: playerId, PlayerAttributes: { groupid: { N: groupId } } }] }; console.log('matchmaking request: ' + JSON .stringify(gameLiftRequestParams)); let ticketId; await GameLift.startMatchmaking(gameLiftRequestParams) .promise().then(data => { if (data && data.MatchmakingTicket) { ticketId = data.MatchmakingTicket.TicketId; } response = { statusCode: 200, body: JSON .stringify({ 'ticketId': ticketId }) }; }) .catch(err => { response = { statusCode: 400, body: JSON .stringify({ error: err }) }; }); return response; }; GetPlayerDataコード const AWS = require(' aws - sdk '); const Cognito = new AWS .CognitoIdentityServiceProvider({region: 'ap-northeast-1'}); const DynamoDb = new AWS .DynamoDB({region: 'ap-northeast-11'}); exports.handler = async (event) => { let response; let raisedError; let accessToken; if (event.headers) { if (event.headers['Authorization']) { accessToken = event.headers['Authorization']; } } const cognitoRequestParams = { AccessToken: accessToken }; let sub; await Cognito.getUser(cognitoRequestParams) .promise().then(data => { if (data && data.UserAttributes) { for (const attribute of data.UserAttributes) { if (attribute.Name == 'sub') { sub = attribute. Value ; break; } } } }) .catch(err => { raisedError = err; }); if (raisedError) { response = { statusCode: 400, body: JSON .stringify({ error: raisedError }) }; return response; } const dynamoDbRequestParams = { TableName: 'Players', Key: { Id: {S: sub} } }; let playerData; await DynamoDb.getItem(dynamoDbRequestParams) .promise().then(data => { if (data && data.Item) { playerData = data.Item; } response = { statusCode: 200, body: JSON .stringify({ 'playerData': playerData }) }; }) .catch(err => { response = { statusCode: 400, body: JSON .stringify({ error: err }) }; }); return response; }; PollMatchMakingコード const AWS = require(' aws - sdk '); const DynamoDb = new AWS .DynamoDB({region: 'ap-northeast-1'}); exports.handler = async (event) => { let response; let ticketId; if (event.body) { const body = JSON .parse(event.body); if (body.ticketId) { ticketId = body.ticketId; } } if (!ticketId) { response = { statusCode: 400, body: JSON .stringify({ error: 'incoming request did not have a ticket id' }) }; return response; } const dynamoDbRequestParams = { TableName: 'MatchmakingTickets', Key: { Id: {S: ticketId} } }; let ticket; await DynamoDb.getItem(dynamoDbRequestParams) .promise().then(data => { if (data && data.Item) { ticket = data.Item; } response = { statusCode: 200, body: JSON .stringify({ 'ticket': ticket }) }; }) .catch(err => { response = { statusCode: 400, body: JSON .stringify({ error: err }) }; }); return response; }; 各関数のアクセス権限を編集します 1.StartMatchmakingの アクセスポリシーに以下の内容を文末に追加します { " Effect ": " Allow ", " Action ": " lambda:InvokeFunction ", " Resource ": " arn:aws:lambda:your_region:your_account:function:GetPlayerData " , } , { " Effect ": " Allow ", " Action ": " gamelift:StartMatchmaking ", " Resource ": " * " , } 2.GetPlayerDataの アクセスポリシーに以下の内容を文末に追加します { " Effect ": " Allow ", " Action ": " Dynamodb:GetItem ", " Resource ": " arn:aws:dynamodb:your_region:your_account:table/Players " , } 3.PollMatchMakingの アクセスポリシーに以下の内容を文末に追加します { " Effect ": " Allow ", " Action ": " Dynamodb:GetItem ", " Resource ": " arn:aws:dynamodb:your_region:your_account:table/MatchmakingTickets " , } API Gateway での API の作成 上記ログイン API の作成と同様な手順で API の作成ページに移動し、以下の内容で API リソースを作成します getplayerdata API リソースの作成 Resource Name:getplayerdata Resource Path : /getplayerdata その他:デフォルト値 startmatchmaking API リソースの作成 Resource Name:startmatchmaking Resource Path:/startmatchmaking pollmatchmaking API リソースの作成 Resource Name:pollmatchmaking Resource Path:/pollMatchmaking API ごとにメソッドを作成します getplayerdata API のGETメソッド作成 統合タイプ:lambda Functionチェック Lambdaリージョン:ap-northeast-1 Lambda 関数:GetPlayerData startmatchmaking API のPOSTメソッド作成 統合タイプ:lambda Functionチェック Lambdaリージョン:ap-northeast-1 Lambda 関数:StartMatchMaking pollmatchmaking API のPOSTメソッド作成 統合タイプ:lambda Functionチェック Lambdaリージョン:ap-northeast-1 Lambda 関数:PollMatchMaking API ごとに認証を設定します 図のように各 API メソッドごとに「メソッドリク エス ト」⇒「認可」にCognito認証を設定します ここまで、Cognitoおよびバックエンド API の作成は、以上となります! Part3 では、バックエンド API を用いてUEクライアントへの組込みおよびマッチングの検証を行います。 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 執筆: @chen.sun 、レビュー: @wakamoto.ryosuke ( Shodo で執筆されました )
こんにちは!金融ソリューション事業部の孫です。 Part1の記事 と Part2の記事 では、マッチングに関する AWS 側のリソースを全部構築しました。 Part3である今回は、構築したバックエンド API をUEクライアントに組み込んでマッチング検証を行います! Part1、Part2が未見の方は、ぜひ内容をご確認いただきたいです! Part1の記事は こちら です! Part2の記事は こちら です! UEクライアントへの API 組み込み 前回の記事 で実装したクライアントをベースに、上記に作成した API の呼び出し機能を実装します。 最後に、組込み完了後のクライアントを使ってマッチング処理をテストします。 CPPファイルの作成 プロジェクトフォルダに移動しUE5 Editorファイル(Gamelift_UE5.uproject)を開きます GameMode CPPを作成します C++ Classes 配下に、右クリックして「New C++ class」を選択します 「Game Mode Base」を選択して「OfflineGameMode」名前のCPPを作成します 下記図のように「world Setting」パネルを開いて、その中の「GameMode Override」項目内容は上記作った「OfflineGameMode」に設定します ログイン画面をコン トロール するCPPを作成します 上記と同様な手順で、「New C++ Class」を選択します 「UserWidget」を選択して「OfflineMainMenuWidget」名前のCPPを作成します VS2019でOfflineGameMode CPPファイルを編集します 「Gamelift_UE5.Build.cs」に「"Http", " Json ", "JsonUtilities"」モジュールを追加します 「OfflineGameMode.cpp」と「OfflineGameMode.h」を以下のように編集します ### <OfflineGameMode.cpp> #include "OfflineGameMode.h" #include "Gamelift_UE5Character.h" #include "UObject/ConstructorHelpers.h" AOfflineGameMode::AOfflineGameMode() { // set default pawn class to our Blueprinted character static ConstructorHelpers::FClassFinder<APawn> PlayerPawnBPClass(TEXT("/Game/ThirdPerson/Blueprints/BP_ThirdPersonCharacter")); if (PlayerPawnBPClass.Class != NULL) { DefaultPawnClass = PlayerPawnBPClass.Class; } } ### <OfflineGameMode.h> #pragma once #include "CoreMinimal.h" #include "GameFramework/GameModeBase.h" #include "OfflineGameMode.generated.h" UCLASS() class GAMELIFT_UE5_API AOfflineGameMode : public AGameModeBase { GENERATED_BODY() public: AOfflineGameMode(); }; VS2019でOfflineMainMenuWidget CPPファイルを編集します プレイヤーがログインボタンを押したら、マッチング処理が開始されます。 処理の流れとしては、「ログインリク エス ト(LoginRequest) ⇒ マッチング開始リク エス ト(StartMatchMakingRequest) ⇒ マッチング結果取得リク エス ト(PollMatchMakingRequest)」順番で API を呼び出し結果を取得します マッチング結果取得にあたり、「GetWorld()->GetTimerManager().SetTimer」タイマーを設定することによって1秒のポーリング間隔で結果を取得しています OfflineMainMenuWidget.hコード #pragma once #include "Http.h" #include "CoreMinimal.h" #include "Blueprint/UserWidget.h" #include "OfflineMainMenuWidget.generated.h" UCLASS() class GAMELIFT_UE5_ API UOfflineMainMenuWidget : public UUserWidget { GENERATED_BODY() public: UOfflineMainMenuWidget(const FObjectInitializer& ObjectInitializer); UFUNCTION(BlueprintCallable) void OnLoginClicked(); UPROPERTY(EditAnywhere) FString ApiGatewayEndpoint; UPROPERTY(EditAnywhere) FString LoginURI; UPROPERTY(EditAnywhere) FString StartMatchMakingURI; UPROPERTY(EditAnywhere) FString PollMatchMakingURI; UPROPERTY(BluePrintReadWrite) FString user; UPROPERTY(BluePrintReadWrite) FString pass; UPROPERTY() FTimerHandle PollMatchmakingHandle; private: FHttpModule* Http; FString IdToken; FString MatchmakingTicketId; void LoginRequest(FString usr, FString pwd ); void OnLoginResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful); void StartMatchMakingRequest(FString idt ); void StartMatchMakingResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful); void PollMatchMakingRequest(); void PollMatchMakingResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful); }; OfflineMainMenuWidget.cppコード #include "OfflineMainMenuWidget.h" #include " Json .h" #include "JsonUtilities.h" #include "Kismet/GameplayStatics.h" UOfflineMainMenuWidget::UOfflineMainMenuWidget(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer) { Http = &FHttpModule::Get(); ApiGatewayEndpoint = FString::Printf(TEXT("「 API URLで書き換え」")); LoginURI = FString::Printf(TEXT("/login")); StartMatchMakingURI = FString::Printf(TEXT("/startmatchmaking")); PollMatchMakingURI = FString::Printf(TEXT("/pollmatchmaking")); IdToken = ""; MatchmakingTicketId = ""; } void UOfflineMainMenuWidget::OnLoginClicked() { LoginRequest(user, pass); } void UOfflineMainMenuWidget::LoginRequest(FString usr, FString pwd ) { TSharedPtr JsonObject = MakeShareable(new FJsonObject()); JsonObject->SetStringField(TEXT("username"), *FString::Printf(TEXT("%s"), *usr)); JsonObject->SetStringField(TEXT("password"), *FString::Printf(TEXT("%s"), * pwd )); FString JsonBody; TSharedRef > JsonWriter = TJsonWriterFactory ::Create(&JsonBody); FJsonSerializer::Serialize(JsonObject.ToSharedRef(), JsonWriter); TSharedRef LoginHttpRequest = Http->CreateRequest(); LoginHttpRequest->SetVerb("POST"); LoginHttpRequest->SetURL(ApiGatewayEndpoint + LoginURI); LoginHttpRequest->SetHeader("Content-Type", "application/ json "); LoginHttpRequest->SetContentAsString(JsonBody); LoginHttpRequest->OnProcessRequestComplete().BindUObject(this, &UOfflineMainMenuWidget::OnLoginResponse); LoginHttpRequest->ProcessRequest(); } void UOfflineMainMenuWidget::OnLoginResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bwasSuccessful) { if (bwasSuccessful) { TSharedPtr JsonObject; TSharedRef > Reader = TJsonReaderFactory ::Create(Response->GetContentAsString()); if (FJsonSerializer::Deserialize(Reader, JsonObject)) { IdToken = JsonObject->GetObjectField("tokens")->GetStringField("IdToken"); StartMatchMakingRequest(IdToken); } } } void UOfflineMainMenuWidget::StartMatchMakingRequest(FString idt ) { TSharedRef StartMatchMakingHttpRequest = Http->CreateRequest(); StartMatchMakingHttpRequest->SetVerb("GET"); StartMatchMakingHttpRequest->SetURL(ApiGatewayEndpoint + StartMatchMakingURI); StartMatchMakingHttpRequest->SetHeader("Content-type", "application/ json "); StartMatchMakingHttpRequest->SetHeader("Authorization", idt ); StartMatchMakingHttpRequest->OnProcessRequestComplete().BindUObject(this, &UOfflineMainMenuWidget::StartMatchMakingResponse); StartMatchMakingHttpRequest->ProcessRequest(); } void UOfflineMainMenuWidget::StartMatchMakingResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bwasSuccessful) { if (bwasSuccessful) { TSharedPtr JsonObject; TSharedRef > Reader = TJsonReaderFactory ::Create(Response->GetContentAsString()); if (FJsonSerializer::Deserialize(Reader, JsonObject)) { if (JsonObject->HasField("ticketId")) { MatchmakingTicketId = JsonObject->GetStringField("ticketId"); GetWorld()->GetTimerManager().SetTimer(PollMatchmakingHandle, this, &UOfflineMainMenuWidget::PollMatchMakingRequest, 1.0f, true, 1.0f); } } } } void UOfflineMainMenuWidget::PollMatchMakingRequest() { TSharedPtr RequestObj = MakeShareable(new FJsonObject); RequestObj->SetStringField("ticketId", MatchmakingTicketId); FString RequestBody; TSharedRef > Writer = TJsonWriterFactory ::Create(&RequestBody); if (FJsonSerializer::Serialize(RequestObj.ToSharedRef(), Writer)) { TSharedRef PollMatchMakingHttpRequest = Http->CreateRequest(); PollMatchMakingHttpRequest->SetVerb("POST"); PollMatchMakingHttpRequest->SetURL(ApiGatewayEndpoint + PollMatchMakingURI); PollMatchMakingHttpRequest->SetHeader("Content-type", "application/ json "); PollMatchMakingHttpRequest->SetHeader("Authorization", IdToken); PollMatchMakingHttpRequest->OnProcessRequestComplete().BindUObject(this, &UOfflineMainMenuWidget::PollMatchMakingResponse); PollMatchMakingHttpRequest->SetContentAsString(RequestBody); PollMatchMakingHttpRequest->ProcessRequest(); } } void UOfflineMainMenuWidget::PollMatchMakingResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bwasSuccessful) { if (bwasSuccessful) { TSharedPtr JsonObject; TSharedRef > Reader = TJsonReaderFactory ::Create(Response->GetContentAsString()); if (FJsonSerializer::Deserialize(Reader, JsonObject)) { FString IpAddress = JsonObject->GetObjectField("PlayerSession")->GetStringField("IpAddress"); FString Port = JsonObject->GetObjectField("PlayerSession")->GetStringField("Port"); TArray > Players = JsonObject->GetObjectField("Players")->GetArrayField("L"); TSharedPtr Player = Players[0]->AsObject()->GetObjectField("M"); FString PlayerSessionId = Player->GetObjectField("PlayerSessionId")->GetStringField("S"); FString PlayerId = Player->GetObjectField("PlayerId")->GetStringField("S"); FString LevelName = IpAddress + ":" + Port; const FString& Options = "?PlayerSessionId=" + PlayerSessionId + "?PlayerId=" + PlayerId; UGameplayStatics::OpenLevel(GetWorld(), FName(*LevelName), false, Options); } } } ユーザーログイン用BluePrintの作成 Blueprintsフォルダに移動し、右クリックして「 User Interface 」⇒「 Widget Blueprint」で「WBP_OfflineMainMenu」BluePrintを作成します 「WBP_OfflineMainMenu」BluePrintを選択し、「Open Level Blueprint」をクリックしてBlueprintエディターを開きます 以下の図のようにBluePrintを編集します ゲーム開始をトリガーとして、ユーザー名とパスワード入力のログイン画面を表示します 「WBP_OfflineMainMenu」BluePrintをダブルクリックし、以下のようにユーザー名とパスワード入力の画面をデザインします Loginボタンの「OnClicked Events」を追加します イベント処理では、プレイヤーが入力したユーザー名とパスワードを取得し、ログイン API をリク エス トします ここまでは、クライアント側の組込みが完了しました。 マッチング機能の検証 今回、ゲームセッションは3つ作成しました。 クライアントを6つ立ち上げて、ログイン後にそれぞれのゲームセッションにランダムにマッチングで振り分けられることを確認します。 ログイン前の状態 クライアント確認 (入力待ちの状態) GameLift側の確認 ゲームセッションが「0」であること ログイン後の状態 クライアント確認 2人ずつマッチングされて各クライアント画面でプレイヤー2人が確認されます GameLift側の確認 ゲームセッションが「3」であること 各ゲームセッション配下にプレイヤーセッションが「2」であること 終わりに 今回はFlexMatch機能を活用して、マッチング基盤を構築してみました。 この基盤は、単なるゲームで使われるものだけではなく、 メタバース 上でも活用可能だと考えられます。例えばイベント開催時にユーザー間のコミュニケーションを増やすことを目的として、同じ地域や性格のユーザーを同じルームに配置する、などの ユースケース が挙げられます。 ゲームで用いられる上記の技術は、将来 メタバース 上でますます活用されていくと思いますので、引き続きゲーム領域のサーバーサイド技術を学習していきたいです。 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 参考 https://docs.aws.amazon.com/ja_jp/gamelift/latest/flexmatchguide/match-events.html https://docs.unrealengine.com/5.0/en-US/setting-up-a-game-mode-in-unreal-engine/ https://docs.unrealengine.com/5.0/en-US/blueprints-visual-scripting-in-unreal-engine/ https://www.youtube.com/watch?v=lhABExDSpHE&list=PLuGWzrvNze7LEn4db8h3Jl325-asqqgP2&index=7 執筆: @chen.sun 、レビュー: @wakamoto.ryosuke ( Shodo で執筆されました )
こんにちは!金融ソリューション事業部の孫です。 Part1の記事 と Part2の記事 では、マッチングに関する AWS 側のリソースを全部構築しました。 Part3である今回は、構築したバックエンド API をUEクライアントに組み込んでマッチング検証を行います! Part1、Part2が未見の方は、ぜひ内容をご確認いただきたいです! Part1の記事は こちら です! Part2の記事は こちら です! UEクライアントへの API 組み込み 前回の記事 で実装したクライアントをベースに、上記に作成した API の呼び出し機能を実装します。 最後に、組込み完了後のクライアントを使ってマッチング処理をテストします。 CPPファイルの作成 プロジェクトフォルダに移動しUE5 Editorファイル(Gamelift_UE5.uproject)を開きます GameMode CPPを作成します C++ Classes 配下に、右クリックして「New C++ class」を選択します 「Game Mode Base」を選択して「OfflineGameMode」名前のCPPを作成します 下記図のように「world Setting」パネルを開いて、その中の「GameMode Override」項目内容は上記作った「OfflineGameMode」に設定します ログイン画面をコン トロール するCPPを作成します 上記と同様な手順で、「New C++ Class」を選択します 「UserWidget」を選択して「OfflineMainMenuWidget」名前のCPPを作成します VS2019でOfflineGameMode CPPファイルを編集します 「Gamelift_UE5.Build.cs」に「"Http", " Json ", "JsonUtilities"」モジュールを追加します 「OfflineGameMode.cpp」と「OfflineGameMode.h」を以下のように編集します ### <OfflineGameMode.cpp> #include "OfflineGameMode.h" #include "Gamelift_UE5Character.h" #include "UObject/ConstructorHelpers.h" AOfflineGameMode::AOfflineGameMode() { // set default pawn class to our Blueprinted character static ConstructorHelpers::FClassFinder<APawn> PlayerPawnBPClass(TEXT("/Game/ThirdPerson/Blueprints/BP_ThirdPersonCharacter")); if (PlayerPawnBPClass.Class != NULL) { DefaultPawnClass = PlayerPawnBPClass.Class; } } ### <OfflineGameMode.h> #pragma once #include "CoreMinimal.h" #include "GameFramework/GameModeBase.h" #include "OfflineGameMode.generated.h" UCLASS() class GAMELIFT_UE5_API AOfflineGameMode : public AGameModeBase { GENERATED_BODY() public: AOfflineGameMode(); }; VS2019でOfflineMainMenuWidget CPPファイルを編集します プレイヤーがログインボタンを押したら、マッチング処理が開始されます。 処理の流れとしては、「ログインリク エス ト(LoginRequest) ⇒ マッチング開始リク エス ト(StartMatchMakingRequest) ⇒ マッチング結果取得リク エス ト(PollMatchMakingRequest)」順番で API を呼び出し結果を取得します マッチング結果取得にあたり、「GetWorld()->GetTimerManager().SetTimer」タイマーを設定することによって1秒のポーリング間隔で結果を取得しています OfflineMainMenuWidget.hコード #pragma once #include "Http.h" #include "CoreMinimal.h" #include "Blueprint/UserWidget.h" #include "OfflineMainMenuWidget.generated.h" UCLASS() class GAMELIFT_UE5_ API UOfflineMainMenuWidget : public UUserWidget { GENERATED_BODY() public: UOfflineMainMenuWidget(const FObjectInitializer& ObjectInitializer); UFUNCTION(BlueprintCallable) void OnLoginClicked(); UPROPERTY(EditAnywhere) FString ApiGatewayEndpoint; UPROPERTY(EditAnywhere) FString LoginURI; UPROPERTY(EditAnywhere) FString StartMatchMakingURI; UPROPERTY(EditAnywhere) FString PollMatchMakingURI; UPROPERTY(BluePrintReadWrite) FString user; UPROPERTY(BluePrintReadWrite) FString pass; UPROPERTY() FTimerHandle PollMatchmakingHandle; private: FHttpModule* Http; FString IdToken; FString MatchmakingTicketId; void LoginRequest(FString usr, FString pwd ); void OnLoginResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful); void StartMatchMakingRequest(FString idt ); void StartMatchMakingResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful); void PollMatchMakingRequest(); void PollMatchMakingResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful); }; OfflineMainMenuWidget.cppコード #include "OfflineMainMenuWidget.h" #include " Json .h" #include "JsonUtilities.h" #include "Kismet/GameplayStatics.h" UOfflineMainMenuWidget::UOfflineMainMenuWidget(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer) { Http = &FHttpModule::Get(); ApiGatewayEndpoint = FString::Printf(TEXT("「 API URLで書き換え」")); LoginURI = FString::Printf(TEXT("/login")); StartMatchMakingURI = FString::Printf(TEXT("/startmatchmaking")); PollMatchMakingURI = FString::Printf(TEXT("/pollmatchmaking")); IdToken = ""; MatchmakingTicketId = ""; } void UOfflineMainMenuWidget::OnLoginClicked() { LoginRequest(user, pass); } void UOfflineMainMenuWidget::LoginRequest(FString usr, FString pwd ) { TSharedPtr JsonObject = MakeShareable(new FJsonObject()); JsonObject->SetStringField(TEXT("username"), *FString::Printf(TEXT("%s"), *usr)); JsonObject->SetStringField(TEXT("password"), *FString::Printf(TEXT("%s"), * pwd )); FString JsonBody; TSharedRef > JsonWriter = TJsonWriterFactory ::Create(&JsonBody); FJsonSerializer::Serialize(JsonObject.ToSharedRef(), JsonWriter); TSharedRef LoginHttpRequest = Http->CreateRequest(); LoginHttpRequest->SetVerb("POST"); LoginHttpRequest->SetURL(ApiGatewayEndpoint + LoginURI); LoginHttpRequest->SetHeader("Content-Type", "application/ json "); LoginHttpRequest->SetContentAsString(JsonBody); LoginHttpRequest->OnProcessRequestComplete().BindUObject(this, &UOfflineMainMenuWidget::OnLoginResponse); LoginHttpRequest->ProcessRequest(); } void UOfflineMainMenuWidget::OnLoginResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bwasSuccessful) { if (bwasSuccessful) { TSharedPtr JsonObject; TSharedRef > Reader = TJsonReaderFactory ::Create(Response->GetContentAsString()); if (FJsonSerializer::Deserialize(Reader, JsonObject)) { IdToken = JsonObject->GetObjectField("tokens")->GetStringField("IdToken"); StartMatchMakingRequest(IdToken); } } } void UOfflineMainMenuWidget::StartMatchMakingRequest(FString idt ) { TSharedRef StartMatchMakingHttpRequest = Http->CreateRequest(); StartMatchMakingHttpRequest->SetVerb("GET"); StartMatchMakingHttpRequest->SetURL(ApiGatewayEndpoint + StartMatchMakingURI); StartMatchMakingHttpRequest->SetHeader("Content-type", "application/ json "); StartMatchMakingHttpRequest->SetHeader("Authorization", idt ); StartMatchMakingHttpRequest->OnProcessRequestComplete().BindUObject(this, &UOfflineMainMenuWidget::StartMatchMakingResponse); StartMatchMakingHttpRequest->ProcessRequest(); } void UOfflineMainMenuWidget::StartMatchMakingResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bwasSuccessful) { if (bwasSuccessful) { TSharedPtr JsonObject; TSharedRef > Reader = TJsonReaderFactory ::Create(Response->GetContentAsString()); if (FJsonSerializer::Deserialize(Reader, JsonObject)) { if (JsonObject->HasField("ticketId")) { MatchmakingTicketId = JsonObject->GetStringField("ticketId"); GetWorld()->GetTimerManager().SetTimer(PollMatchmakingHandle, this, &UOfflineMainMenuWidget::PollMatchMakingRequest, 1.0f, true, 1.0f); } } } } void UOfflineMainMenuWidget::PollMatchMakingRequest() { TSharedPtr RequestObj = MakeShareable(new FJsonObject); RequestObj->SetStringField("ticketId", MatchmakingTicketId); FString RequestBody; TSharedRef > Writer = TJsonWriterFactory ::Create(&RequestBody); if (FJsonSerializer::Serialize(RequestObj.ToSharedRef(), Writer)) { TSharedRef PollMatchMakingHttpRequest = Http->CreateRequest(); PollMatchMakingHttpRequest->SetVerb("POST"); PollMatchMakingHttpRequest->SetURL(ApiGatewayEndpoint + PollMatchMakingURI); PollMatchMakingHttpRequest->SetHeader("Content-type", "application/ json "); PollMatchMakingHttpRequest->SetHeader("Authorization", IdToken); PollMatchMakingHttpRequest->OnProcessRequestComplete().BindUObject(this, &UOfflineMainMenuWidget::PollMatchMakingResponse); PollMatchMakingHttpRequest->SetContentAsString(RequestBody); PollMatchMakingHttpRequest->ProcessRequest(); } } void UOfflineMainMenuWidget::PollMatchMakingResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bwasSuccessful) { if (bwasSuccessful) { TSharedPtr JsonObject; TSharedRef > Reader = TJsonReaderFactory ::Create(Response->GetContentAsString()); if (FJsonSerializer::Deserialize(Reader, JsonObject)) { FString IpAddress = JsonObject->GetObjectField("PlayerSession")->GetStringField("IpAddress"); FString Port = JsonObject->GetObjectField("PlayerSession")->GetStringField("Port"); TArray > Players = JsonObject->GetObjectField("Players")->GetArrayField("L"); TSharedPtr Player = Players[0]->AsObject()->GetObjectField("M"); FString PlayerSessionId = Player->GetObjectField("PlayerSessionId")->GetStringField("S"); FString PlayerId = Player->GetObjectField("PlayerId")->GetStringField("S"); FString LevelName = IpAddress + ":" + Port; const FString& Options = "?PlayerSessionId=" + PlayerSessionId + "?PlayerId=" + PlayerId; UGameplayStatics::OpenLevel(GetWorld(), FName(*LevelName), false, Options); } } } ユーザーログイン用BluePrintの作成 Blueprintsフォルダに移動し、右クリックして「 User Interface 」⇒「 Widget Blueprint」で「WBP_OfflineMainMenu」BluePrintを作成します 「WBP_OfflineMainMenu」BluePrintを選択し、「Open Level Blueprint」をクリックしてBlueprintエディターを開きます 以下の図のようにBluePrintを編集します ゲーム開始をトリガーとして、ユーザー名とパスワード入力のログイン画面を表示します 「WBP_OfflineMainMenu」BluePrintをダブルクリックし、以下のようにユーザー名とパスワード入力の画面をデザインします Loginボタンの「OnClicked Events」を追加します イベント処理では、プレイヤーが入力したユーザー名とパスワードを取得し、ログイン API をリク エス トします ここまでは、クライアント側の組込みが完了しました。 マッチング機能の検証 今回、ゲームセッションは3つ作成しました。 クライアントを6つ立ち上げて、ログイン後にそれぞれのゲームセッションにランダムにマッチングで振り分けられることを確認します。 ログイン前の状態 クライアント確認 (入力待ちの状態) GameLift側の確認 ゲームセッションが「0」であること ログイン後の状態 クライアント確認 2人ずつマッチングされて各クライアント画面でプレイヤー2人が確認されます GameLift側の確認 ゲームセッションが「3」であること 各ゲームセッション配下にプレイヤーセッションが「2」であること 終わりに 今回はFlexMatch機能を活用して、マッチング基盤を構築してみました。 この基盤は、単なるゲームで使われるものだけではなく、 メタバース 上でも活用可能だと考えられます。例えばイベント開催時にユーザー間のコミュニケーションを増やすことを目的として、同じ地域や性格のユーザーを同じルームに配置する、などの ユースケース が挙げられます。 ゲームで用いられる上記の技術は、将来 メタバース 上でますます活用されていくと思いますので、引き続きゲーム領域のサーバーサイド技術を学習していきたいです。 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 参考 https://docs.aws.amazon.com/ja_jp/gamelift/latest/flexmatchguide/match-events.html https://docs.unrealengine.com/5.0/en-US/setting-up-a-game-mode-in-unreal-engine/ https://docs.unrealengine.com/5.0/en-US/blueprints-visual-scripting-in-unreal-engine/ https://www.youtube.com/watch?v=lhABExDSpHE&list=PLuGWzrvNze7LEn4db8h3Jl325-asqqgP2&index=7 執筆: @chen.sun 、レビュー: @wakamoto.ryosuke ( Shodo で執筆されました )
こんにちは!金融ソリューション事業部の孫です。 Part1の記事 では、FlexMatchに関する コンポーネント の構築をご紹介しました! さて、Part2である今回は、 プレイヤーの認証・管理用Cognitoの作成 UEクライアントの組込みに関するバックエンド API の作成 をご紹介します! Part2の続きとして、Part3の記事は こちら です! プレイヤー管理用 Amazon Cognitoの作成 マッチング検証にあたり、複数プレイヤーの登録が必要になります。 その為、プレイヤーの認証、承認および管理をシンプルにする為、 Amazon Cognitoを採用します。 AWS マネジメントコンソールでCognitoのトップページに移動します 左側Menu「ユーザープール」⇒「ユーザープールの作成」ボタンをクリックして作成ページを開きます Cognitoの設定は、以下の内容を入力します Pool Name: 「GameLiftUnreal-UserPool」とします 「Review Defauls」をクリックし、設定を確認した上で、プールを作成します ※Tips: ここまでの設定はプール作成後、変更できないため、ちゃんと確認してから前に進めてください プールの作成完了後、左Menuに「App Clients」⇒ 「Add an app client」をクリックして以下の内容でAPPクライアントを作成します App client name:「GameLiftUnreal-LambdaLoginClient」としました 「Generate client secret」のチェックを外します 「Auth Flows Configuration」: 「Enable username password based authentication」と「Enable refresh token based authentication」のみチェックします そのほか:デフォルト値 左menu「App Integration」⇒「App Client Settings」をクリックし設定を行います Cognito User Pool: チェック入れます Sign in and sign out URLs -> CallBack URL(s): https://aws.amazon.com Sign in and sign out URLs -> Sign out URL(s): https://aws.amazon.com 左menu「App Integration」⇒「Domain name」のDomain Prefix: 今回は「gameliftunreal-cog-20230222」 としました ここまで、Cognitoの構築は完了しました。 次に、UEクライアントとCognito連携用の API を構築します。 AWS マネジメントコンソールでLambdaのトップページに移動します 「関数の作成」ボタンをクリックして、関数作成ページを開きます 以下の内容で関数を作成します。 関数名:「GameLiftUnreal-CognitoLogin」としました ランタイム:「Python3.9」としました 関数コードは、以下の内容を入力します 関数の役割としては、プレイヤーが入力したユーザー名とパスワードをCognitoに送信し、その認証結果をプレイヤーに返却します GameLiftUnreal-CognitoLoginコード import boto3 import os import sys USER_POOL_APP_CLIENT_ID = 'Cognitoで生成したクライアントアプリへのアクセスキー' client = boto3.client('cognito-idp') def lambda_handler(event, context): if 'username' not in event or 'password' not in event: return { 'status': 'fail', 'msg': 'Username and password are required' } resp, msg = initiate_auth(event['username'], event['password']) if msg != None: return { 'status': 'fail', 'msg': msg } return { 'status': 'success', 'tokens': resp['AuthenticationResult'] } def initiate_auth(username, password): try: resp = client.initiate_auth( ClientId=USER_POOL_APP_CLIENT_ID, AuthFlow='USER_PASSWORD_AUTH', AuthParameters={ 'USERNAME': username, 'PASSWORD': password }) except client.exceptions.InvalidParameterException as e: return None, "Username and password must not be empty" except (client.exceptions.NotAuthorizedException, client.exceptions.UserNotFoundException) as e: return None, "Username or password is incorrect" except Exception as e: print("Uncaught exception:", e, file=sys.stderr) return None, "Unknown error" return resp, None Cognitoへのアクセスが必要なため、アクセス権限を追加します 「設定」⇒「アクセス権限」で、対象ロールをクリックしてアクセス権限編集ページにとばされます JSON タブの配下に、以下の JSON 文を文末に追記します { " Sid ": " VisualEditor0 ", " Effect ": " Allow ", " Action ": " cognito-idp:InitiateAuth ", " Resource ": " * " } ここまでは、GameLiftUnreal-CognitoLogin関数を作成しました。 次に、 API Gateway において、関数を外から呼びだす API を作成します。 AWS マネジメントコンソールの検索窓口に「 API Gateway 」を入力して API Gateway のトップページに移動します RestAPIを選択し、「構築」ボタンを押して API の作成ページを開きます 以下の内容で API を作成します プロトコル を選択する:Rest 新しい API の作成 : New API 名前と説明 API 名 : 「GameLiftUnreal- API 」としました その他:デフォルト値 「 API の作成」ボタンを押して API を作成しました。 作成した API を開いて「アクション」⇒「リソースの作成」でloginリソースを作成します 「アクション」⇒「メソッドの作成」でPOSTメソッドを作成します 統合タイプ:lambda Functionチェック Lambdaリージョン:ap-northeast-1 Lambda 関数:前手順で作成したGameLiftUnreal-CognitoLoginを選択 プレイヤーのIDとパスワードを入力した上で、ログイン API にリク エス トしたら、Cognitoからアクセスキーを発行してくれます。 そして、発行されたアクセスキーを用いて、他の API にも正常にリク エス トができます。 マッチング処理用のバックエンド API の作成 上記AmazonSNS作成時に説明した通り、各マッチングイベントごとに後続処理の実装が必要なのですが、今回は検証をシンプルにする目的で「StartMatchMaking」イベントのみ処理します。 また、その他の補助処理として「①GetPlayerData:プレイヤーデータ取得」と「②PollMatchMaking:マッチング結果取得」も実装します。 バックエンドのLambda関数を作成します 上記のLambda作成と同様な手順でLambda作成ページに移動し、以下の内容で関数本体を作成します StartMatchmaking 関数の役割としては、マッチングで必要なデータをインプットしてマッチングを開始します 関数名:「StartMatchmaking」としました ランタイム:「NodeJs 18.x」としました GetPlayerData 関数の役割としては、現在ログイン中のプレイヤー情報を取得します 関数名:「GetPlayerData」としました ランタイム:「NodeJs 18.x」としました PollMatchMakingのLambda関数定義 関数の役割としては、マッチング結果を取得します(ポーリング方式) 関数名:「PollMatchMaking」としました ランタイム:「NodeJs 18.x」としました 上記作成した関数本体を開いて、対象の ソースコード を入力します StartMatchmakingコード const AWS = require(' aws - sdk '); const Lambda = new AWS .Lambda({region: 'ap-northeast-1'}); const GameLift = new AWS .GameLift({region: 'ap-northeast-1'}); exports.handler = async (event) => { let response; let raisedError; let latencyMap; if (event.body) { const body = JSON .parse(event.body); if (body.latencyMap) { latencyMap = body.latencyMap; } } if (!latencyMap) { response = { statusCode: 400, body: JSON .stringify({ error: 'incoming request did not have a latency map' }) }; return response; } const lambdaRequestParams = { FunctionName: 'GetPlayerData', Payload: JSON .stringify(event) }; let playerData; await Lambda. invoke (lambdaRequestParams) .promise().then(data => { if (data && data.Payload) { const payload = JSON .parse(data.Payload); if (payload.body) { const payloadBody = JSON .parse(payload.body); playerData = payloadBody.playerData; } } }) .catch(err => { raisedError = err; }); if (raisedError) { response = { statusCode: 400, body: JSON .stringify({ error: raisedError }) }; return response; } else if (!playerData) { response = { statusCode: 400, body: JSON .stringify({ error: 'unable to retrieve player data' }) }; return response; } const playerId = playerData.Id.S; const groupId = parseInt(playerData.groupId.N, 10); const gameLiftRequestParams = { ConfigurationName: 'GameLiftTutorialMatchmaker', Players: [{ LatencyInMs: latencyMap, PlayerId: playerId, PlayerAttributes: { groupid: { N: groupId } } }] }; console.log('matchmaking request: ' + JSON .stringify(gameLiftRequestParams)); let ticketId; await GameLift.startMatchmaking(gameLiftRequestParams) .promise().then(data => { if (data && data.MatchmakingTicket) { ticketId = data.MatchmakingTicket.TicketId; } response = { statusCode: 200, body: JSON .stringify({ 'ticketId': ticketId }) }; }) .catch(err => { response = { statusCode: 400, body: JSON .stringify({ error: err }) }; }); return response; }; GetPlayerDataコード const AWS = require(' aws - sdk '); const Cognito = new AWS .CognitoIdentityServiceProvider({region: 'ap-northeast-1'}); const DynamoDb = new AWS .DynamoDB({region: 'ap-northeast-11'}); exports.handler = async (event) => { let response; let raisedError; let accessToken; if (event.headers) { if (event.headers['Authorization']) { accessToken = event.headers['Authorization']; } } const cognitoRequestParams = { AccessToken: accessToken }; let sub; await Cognito.getUser(cognitoRequestParams) .promise().then(data => { if (data && data.UserAttributes) { for (const attribute of data.UserAttributes) { if (attribute.Name == 'sub') { sub = attribute. Value ; break; } } } }) .catch(err => { raisedError = err; }); if (raisedError) { response = { statusCode: 400, body: JSON .stringify({ error: raisedError }) }; return response; } const dynamoDbRequestParams = { TableName: 'Players', Key: { Id: {S: sub} } }; let playerData; await DynamoDb.getItem(dynamoDbRequestParams) .promise().then(data => { if (data && data.Item) { playerData = data.Item; } response = { statusCode: 200, body: JSON .stringify({ 'playerData': playerData }) }; }) .catch(err => { response = { statusCode: 400, body: JSON .stringify({ error: err }) }; }); return response; }; PollMatchMakingコード const AWS = require(' aws - sdk '); const DynamoDb = new AWS .DynamoDB({region: 'ap-northeast-1'}); exports.handler = async (event) => { let response; let ticketId; if (event.body) { const body = JSON .parse(event.body); if (body.ticketId) { ticketId = body.ticketId; } } if (!ticketId) { response = { statusCode: 400, body: JSON .stringify({ error: 'incoming request did not have a ticket id' }) }; return response; } const dynamoDbRequestParams = { TableName: 'MatchmakingTickets', Key: { Id: {S: ticketId} } }; let ticket; await DynamoDb.getItem(dynamoDbRequestParams) .promise().then(data => { if (data && data.Item) { ticket = data.Item; } response = { statusCode: 200, body: JSON .stringify({ 'ticket': ticket }) }; }) .catch(err => { response = { statusCode: 400, body: JSON .stringify({ error: err }) }; }); return response; }; 各関数のアクセス権限を編集します 1.StartMatchmakingの アクセスポリシーに以下の内容を文末に追加します { " Effect ": " Allow ", " Action ": " lambda:InvokeFunction ", " Resource ": " arn:aws:lambda:your_region:your_account:function:GetPlayerData " , } , { " Effect ": " Allow ", " Action ": " gamelift:StartMatchmaking ", " Resource ": " * " , } 2.GetPlayerDataの アクセスポリシーに以下の内容を文末に追加します { " Effect ": " Allow ", " Action ": " Dynamodb:GetItem ", " Resource ": " arn:aws:dynamodb:your_region:your_account:table/Players " , } 3.PollMatchMakingの アクセスポリシーに以下の内容を文末に追加します { " Effect ": " Allow ", " Action ": " Dynamodb:GetItem ", " Resource ": " arn:aws:dynamodb:your_region:your_account:table/MatchmakingTickets " , } API Gateway での API の作成 上記ログイン API の作成と同様な手順で API の作成ページに移動し、以下の内容で API リソースを作成します getplayerdata API リソースの作成 Resource Name:getplayerdata Resource Path : /getplayerdata その他:デフォルト値 startmatchmaking API リソースの作成 Resource Name:startmatchmaking Resource Path:/startmatchmaking pollmatchmaking API リソースの作成 Resource Name:pollmatchmaking Resource Path:/pollMatchmaking API ごとにメソッドを作成します getplayerdata API のGETメソッド作成 統合タイプ:lambda Functionチェック Lambdaリージョン:ap-northeast-1 Lambda 関数:GetPlayerData startmatchmaking API のPOSTメソッド作成 統合タイプ:lambda Functionチェック Lambdaリージョン:ap-northeast-1 Lambda 関数:StartMatchMaking pollmatchmaking API のPOSTメソッド作成 統合タイプ:lambda Functionチェック Lambdaリージョン:ap-northeast-1 Lambda 関数:PollMatchMaking API ごとに認証を設定します 図のように各 API メソッドごとに「メソッドリク エス ト」⇒「認可」にCognito認証を設定します ここまで、Cognitoおよびバックエンド API の作成は、以上となります! Part3 では、バックエンド API を用いてUEクライアントへの組込みおよびマッチングの検証を行います。 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 執筆: @chen.sun 、レビュー: @wakamoto.ryosuke ( Shodo で執筆されました )
こんにちは!金融ソリューション事業部の孫です。 今回の記事では、GameLiftを用いたUnrealEngineゲームセッションのマッチング基盤の構築をご紹介します! 実施事項が多い為、Part1~3の3記事に分けて連載します。 また、GameLiftを用いたゲームセッションの作成、接続については 前回の記事 でご紹介しておりますので、そちらもご覧いただければ幸いです。 Part1である今回は、 AWS GameLiftにおいてFlexMatchに関する コンポーネント の構築を行います。 Part2の記事は こちら です! Part3の記事は こちら です! はじめに 冒頭でGameLiftを用いてマッチング基盤を構築することを述べましたが、具体的にはGameLiftが提供するFlexMatch機能を利用します。 FlexMatch機能 とは、カスタマイズ可能なマッチメイキングサービスです。 特徴として、下記が挙げられます。 簡単にゲームにあったカスタムルールを複数作れる サーバーとの間の レイテンシー を基準にマッチング可能 時間と共にルールの条件を緩和することも可能 ユーザーによるマッチング結果の承諾機能 キューを使用してゲームセッションを効率的に配置 FlexMatchを使用することで、フレキシブルなマッチング処理を簡単に実現でき、 マルチプレイヤー ゲームをさらに楽しむことができます! 今回の検証では、GameLift以外他の AWS コンポーネント (Cognito、AmazonSNS、 Dynamo 、 API Gateway 、Lambda)も利用しています。説明をシンプルにする為、それらの コンポーネント の解説は割愛いたします。 マッチング基盤の構築手順 手順は、以下のとおりです。 【Part1】 1.ゲームセッション保管用キューの作成 2.マッチングイベント発行用 Amazon SNS の作成 3.マッチングイベント保管用 Amazon DynamoDB の作成 4.FlexMatchの構築 【Part2】 5.プレイヤー管理用 Amazon Cognitoの作成 6.マッチング処理用のバックエンド API の作成 【Part3】 7.UEクライアントへの API 組み込み 8.マッチング機能の検証 AWS の アーキテクチャ 構築全体像は、以下の アーキテクチャ 図をご参照ください。 今回はGameLiftが担当するゲーム用の クラウド サーバーの管理以外にも、以下の機能を実装します。 プレイヤーアカウントの作成、および認証認可管理(Cognito) マッチングをコン トロール するバックエンドサービス(Lambda関数、 SNS ) マッチング機能をクライアントから呼びだす API ( API Gateway ) マッチングイベントとプレイヤーデータの一時保管(DynamoDB) プレイヤーはまず Cognito 認証でアクセスキーを取得し、次にアクセスキーを用いてマッチング API にリク エス トし、最後にマッチング結果を取得してマッチングを完了します。 マッチングは現在オンライン中のプレイヤーを対象に、事前に設定したルールセットに従って処理を行います。 使用環境/ツール Unreal Engine 5.1.0 Windows10 21H2 x64 RAM 64GM, SSD 1TB NVIDIA GeForce GTX 3080Ti Gamelift SDK GameLift Cpp ServerSDK 3.4.2 GameLift Unreal plugin 3.4.0 Visual Studio 2019 version 16.11.21(以下VS2019) JavaSDK 1.8.0 ゲームセッション保管用キューの作成 FlexMatchを使ってプレイヤーマッチングを行うにあたり、一時的なゲームセッションを保管するキューを作成することで、Fleet IQサービスの最大活用およびマッチング遅延の改善を図ることができます。 GameLift ダッシュ ボードページで、「 ダッシュ ボード」⇒「キュー作成する」をクリックしてキュー作成ページを開きます キュー名を入れた上、「キューの作成」ボタンを押して、キューの作成を完了します ※今回は検証のため、その他設定はデフォルト値のままで十分です ※キューの作成にあたり、事前にFleetの作成が必要です。具体的な手順は 前回の記事 をご参照ください キュー名:「GameLift_PoC_Queue」としました マッチングイベント発行用 Amazon SNS の作成 AmazonSNS( Amazon Simple Notification Serviceの省略)の機能を利用することで、マッチング中にGameLiftが出力したイベントを非同期で拾ってくれます。 イベントによって現在マッチングのステータスを把握した上で、 ネクス トアクションを決めます。 イベントの種類については、以下が挙げられます。 MatchmakingSearching(マッチング中) PotentialMatchCreated(マッチング候補が作成済「プレイヤーの承諾前」) AcceptMatch(マッチング候補を承諾済) AcceptMatchCompleted(プレイヤーの承諾・却下または承諾の タイムアウト により、マッチングの承諾プロセス完了) MatchMakingSucceded(マッチングが正常に完了し、ゲームセッションが作成済) MatchMakingTimedOut( タイムアウト によってマッチング失敗) MatchMakingCancelled(マッチングがキャンセル済) MatchMakingFailed(マッチングでエラーが発生) ※Tips:マッチング処理をよりよくスムーズに実装するため、各イベントの返却文 参照資料 を覚えておくことをお勧めします 。 これから、AmazonSNSを作成します。 AWS マネジメントコンソールの検索窓口に「 SNS 」を入力して、 SNS のトップページに移動します 左側Menu「Topic」⇒「Topicの作成」をクリックして、Topicの作成画面が表示されます 以下の内容を入力して、「Topicの作成」ボタンを押して、 SNS を作成します 名前:「FlexMatchEventNofications」としました アクセスポリシー - オプション:アドバンストを選択し、以下の JSON 文を文末に入力します。 { " Sid ": " __console_pub_0 ", " Effect ": " Allow ", " Principal ": { " Service ": " gamelift.amazonaws.com " } , " Action ": " SNS:Publish ", " Resource ": " arn:aws:sns:your_region:your_account:your_topic_name " , } マッチングイベント保管用 Amazon DynamoDB の作成 今回の検証では、DynamoDBの役割として二つがあります AmazonSNSによって転送されたGameLiftのイベントを保管する オンライン中のプレイヤーIDやそのほかの必要な情報を一時的に預ける これから、DynamoDBを作成します。 AWS マネジメントコンソールの検索窓口に「DynamoDB」を入力して、DynamoDBのトップページに移動します 「テーブルの作成」をクリックして、イベント保管テーブルを作成します ※DynamoDBはNoSQLなので、データベースの作成がなくテーブルから作成します。 テーブル名:「MatchmakingTickets」としました パーティション キー:「Id」としました そのほかの設定:デフォルト値のまま 上記と同様な手順で、オンライン中のプレイヤーデータを保管するテーブルを作成します テーブル名:「Players」としました パーティション キー:「Id」としました そのほかの設定:デフォルト値のまま 作成したテーブルのOverviewページを開いて「Table details」⇒ 「Manage TTL 」をクリックして、 TTL Attributeを追加します。 ※DynamoDBに保存したデータは、基本は一時的なデータなので、 TTL を設定することによって自動的に消してもらうことを図ります TTL Attribute名:「 ttl 」としました ここまで、GameLiftのイベント保管テーブルを作成しました。 これから、AmazonSNSと処理連動するLambda関数を作成します。 ※Lambda関数の動作としては、AmazonSNSから受け取ったイベント情報をDynamoDBに保存します。 AWS マネジメントコンソールでLambdaのトップページに移動します 「関数の作成」ボタンをクリックして作成ページを開きます 以下の内容で関数を作成します 関数名:「TrackEvents」としました ランタイム:「NodeJs 18.x」としました 関数コードについて、以下の ソースコード を入力します 関数の役割としては、GameLiftが発行されたイベントを受け取って中身(イベント番号、イベント種類、ゲームセッション、プレイヤー情報)をDynamoDBに保存する TrackEventsコード const AWS = require(' aws - sdk '); const DynamoDb = new AWS .DynamoDB({region: 'ap-northeast-1'}); exports.handler = async (event) => { let message; let response; if (event.Records && event.Records.length > 0) { const record = event.Records[0]; if (record. Sns && record. Sns .Message) { console.log('message from gamelift: ' + record. Sns .Message); message = JSON .parse(record. Sns .Message); } } if (!message || message['detail-type'] != 'GameLift Matchmaking Event') { response = { statusCode: 400, body: JSON .stringify({ error: 'no message available or message is not about gamelift matchmaking' }) }; return response; } const messageDetail = message.detail; const dynamoDbRequestParams = { RequestItems: { MatchmakingTickets: [] } }; if (!messageDetail.tickets || messageDetail.tickets.length == 0) { response = { statusCode: 400, body: JSON .stringify({ error: 'no tickets found' }) }; return response; } if (messageDetail.type == 'MatchmakingSucceeded' || messageDetail.type == 'MatchmakingTimedOut' || messageDetail.type == 'MatchmakingCancelled' || messageDetail.type == 'MatchmakingFailed') { for (const ticket of messageDetail.tickets) { const ticketItem = {}; ticketItem.Id = {S: ticket.ticketId}; ticketItem.Type = {S: messageDetail.type}; ticketItem. ttl = {N: (Math.floor(Date.now() / 1000) + 3600).toString()}; if (messageDetail.type == 'MatchmakingSucceeded') { ticketItem.Players = {L: []}; const players = ticket.players; for (const player of players) { const playerItem = {M: {}}; playerItem.M.PlayerId = {S: player.playerId}; if (player.playerSessionId) { playerItem.M.PlayerSessionId = {S: player.playerSessionId}; } ticketItem.Players.L.push(playerItem); } ticketItem.GameSessionInfo = { M: { IpAddress: {S: messageDetail.gameSessionInfo.ipAddress}, Port: {N: messageDetail.gameSessionInfo.port.toString()} } }; } dynamoDbRequestParams.RequestItems.MatchmakingTickets.push({ PutRequest: { Item: ticketItem } }); } } await DynamoDb.batchWriteItem(dynamoDbRequestParams) .promise().then(data => { response = { statusCode: 200, body: JSON .stringify({ success: 'ticket data has been saved to dynamodb' }) }; }) .catch(err => { response = { statusCode: 400, body: JSON .stringify({ error: err }) }; }); return response; }; DynamoDBへのアクセスが必要なため、アクセス権限に追加します 「設定」⇒「アクセス権限」で、対象ロールをクリックしてアクセス権限編集ページにとばされます JSON タブの配下に、以下の JSON 文を文末に追記します { " Effect ": " Allow ", " Action ": " dynamodb : BatchWriteItem " " Resource ": " 「関数のARN入力」 " } ここまで、DynamoDBのテーブルとイベント登録用のLambdaを作成しました。 FlexMatchの構築 FlexMatch構築用のリソースを全部そろっているため、これから、FlexMatchルールセットの設定およびMatchmakingの設定を行います。 Matchmaking のルールセットを設定します GameLiftの ダッシュ ボードに移動し「マッチメーキングルールセットの作成」をクリックしてマッチングルール設定ページに移動します ルールセットの設定ページで以下の内容を入力します ルールセット名:「PocRuleSet」としました ルールセット: プレイヤーのチームを2つ作成します 各チームに1名のプレイヤーを含めます 各プレイヤーには所属グループ(groupid)という属性があります 最終的に両チームのプレイヤー数、かつgroupidは同じにする必要があります ルールセット内容 { "name": "poc_test", "ruleLanguageVersion": "1.0", "playerAttributes": [{ "name": "groupid", "type": "number", "default": 1 }], "teams": [{ "name": "play1", "maxPlayers": 1, "minPlayers": 1 }, { "name": "play2", "maxPlayers": 1, "minPlayers": 1 }], "rules": [{ "name": "EqualGroupId", "description": "Only launch a game when the group id of players in each team matches", "type": "comparison", "measurements": [ "teams[play1].attributes[groupid])" ], "referenceValue": "teams[play2].attributes[groupid])", "operation": "=" },{ "name": "EqualTeamSizes", "description": "Only launch a game when the number of players in each team matches", "type": "comparison", "measurements": [ "count(teams[play1].players)" ], "referenceValue": "count(teams[play2].players)", "operation": "=" }] } 「ルールセットの作成」をクリックして作成します Matchmaking Configurationを設定します GameLiftの ダッシュ ボードで「マッチメーキング設定の作成」をクリックしてマッチメーキングの設定ページに移動します 以下の内容でマッチメーキングの設定を行います 名前: 「GameLiftPOCMarchmaker」としました キュー: ap-northeast-1、GameLift_PoC_Queue(手順1で作成したキュー名) リク エス トの タイムアウト :60 ルールセット名: PocRuleSet(前手順で作成したルールセット) 通知先:手順2で作成したFlexMatchEventNoficationsのARN番号 そのほか:デフォルト値 Part1 FlexMatchに関する コンポーネント の構築は、以上となります! Part2の記事は こちら です! Part3の記事は こちら です! 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 執筆: @chen.sun 、レビュー: @wakamoto.ryosuke ( Shodo で執筆されました )
こんにちは!金融ソリューション事業部の山下です。 本記事では Houdini で作成したトゥーン調エフェクトを、VAT(Vertex Animation Texture)という手法を用いて Unreal Engine で利用する方法を紹介します。 Houdiniはプロシージャルにノードベースで3DCG制作が可能なDCCツールです。特にシミュレーションの機能が充実しており、特に映画/ゲーム業界等の VFX 領域で活用されております。 VAT(Vertex Animation Texture)とは 実施環境/ツール 実施手順 1. Houdiniでベースとなるジオメトリを作成 2. FLIP Solverでシミュレーションを実施 3. シミュレーション結果をメッシュに加工してVATとしてExport 4. Unreal EngineでVATを読み込み 5. Niagara Systemでエフェクトを作成 6. BluePrintでエフェクト発生ロジックを組み込み 完成イメージ 所感 参考 VAT(Vertex Animation Texture)とは VATとは文字通り、メッシュの各頂点(Vertex)に対応するアニメーション情報を、テクスチャ画像として マッピング したデータを用いて ゲームエンジン などで利用する手法です。 以下画像のように、横軸に頂点情報、縦軸を時系列(フレーム)情報として マッピング しています。これを用いて、アニメーションに必要な位置情報や回転情報を伝えることができます。 VATを用いるメリットは、クオリティとパフォーマンスです。 今回のようにHoudiniで作成したアニメーションを、 Unreal Engine やUnityなどで比較的低いパフォーマンス負荷で利用可能になります。その為、VATは主にゲーム業界などで活用されています。 こちらのような素晴らしい作例 もあるので、ぜひご覧いただけると幸いです。 VATの詳細については こちらのSidefx公式チュートリアル が詳しいので、ご興味ある方はご覧ください。 実施環境/ツール OS: Windows 11 pro GPU : NVIDIA GeForce RTX 3070Ti Laptop DCC:Houdini Indie version 19.5.534 Game Engine: Unreal Engine 5.1.1 実施手順 Houdiniで元となるジオメトリを作成 FLIP Solverでシミュレーションを実施 シミュレーション結果をメッシュに加工してVATとしてExport Unreal Engine でVATを読み込み Niagara Systemでエフェクトを作成 BluePrintでエフェクト発生ロジックを組み込み 1. Houdiniでベースとなるジオメトリを作成 シミュレーションを適用する為のソースを作成します。 ネットワークの全体像は以下のとおりです。主要な処理に絞って解説します。 最初に Sphere を作成します。 後続工程でノイズをかける為、Primitive Typeを「Polygon」、Frequencyを6に設定します。 Mountain(Attribute Noise)ノードでノイズを適用します。 好みの形になるようにNoise Value やNoise Patternを調整します。 Nullノードとして「SOURCE」と 命名 します。 ここまでのジオメトリデータを用いて、シミュレーションのソースとして扱います。 法線ベクトルを用いて、シミュレーションの初期速度値を定義していきます。 Normalノードで法線ベクトルを作成します。 Add Normals toは「Points」に設定します。 fluidsourceノードで、法線ベクトルからVolumeを作成します。 法線ベクトルデータをvelとして変換する為に、Velocity VolumesタブのSource Attributreを「N」と入力します。 速度が追加されました。 Node Infoを見ると、Voxelデータが確認できます。 Voxel数は9,660、Sizeは0.1。density以外に、速度データとしてvelが追加されていることを確認できます。 「VEL」という名称でNullノードを作成して、ソースジオメトリの作成は完了です。 2. FLIP Solverでシミュレーションを実施 1.で作成したVELノードを選択した状態で、Particle Fluidsタブの「FLIP Fluid from Object」を選択し、Enterを押下します。 AutoDopNetworkが作成されます。 DOPネットワーク内で、以下のシンプルなネットワークを作成します。 FLIP Objectノードで、ソースとなるParticleの情報を設定します。 Particleを細かくする為に、Particle Separationを0.05に設定します。 Initial DataタブのSOP Pathに、1.で生成したSOURCEを設定します。 Source Volumeノードを設定します。 Volume Pathに、1.で作成したVELノードのパスを設定します。 Volume OperationのVelocityプルダウン項目に「Add」を設定します。 SOP To DOP BindingsタブのVelocity Volumeに「vel」を設定します。 FLIP Solverノードに対して、FLIP ObjectノードとSource Volumeノードを接続します。こちらはデフォルトのままで構いません。 デフォルトで配置されているGravityノードなどは削除します。 以下のシミュレーションが完成しました。 3. シミュレーション結果をメッシュに加工してVATとしてExport 新たにGeometoryを作成して、以下のネットワークを構築します。名称はPrepare_Meshとします。 最初にDOP Import FieldsノードでDOPのシミュレーション結果をimportします。 Particle Fluid Surface ノードでParticleからメッシュを作成します。 メッシュを自然に消滅させる為に、FilteringタブのErodeにキーフレームを打ちます。 今回は25フレームを2、50フレームで4に設定することで、自然な消滅を表現します。 33フレーム時点: 40フレーム時点: 50フレーム時点: 初期のアニメーションは不要なので、Time Shiftノードを使用します。 Frameに「$F+10」とすることで初期10フレームを省略します。 ConvertノードでMeshからPolygonに変換します。 PolyReduceノードでPolygon数を削減します。 Number To Keepに2500を指定します。 Polygon数は58955から2599、15.55%に削減されました。 Attribute Blur ノードで各ポリゴンの位置を滑らかにします。 ポリゴン数の削減によるクオリティ低下を補う効果があります。 Blur 前: Blur 後: Normalノードで、Pointsに対して法線ベクトルを追加します。 レンダリング 時により滑らかな表面になります。 Labs Delete Small Partsノードで、微細なポリゴンを削除します。 UV UnwrapノードでUVを設定します。 Nullノードに「VAT_OUT」と 命名 して、Houdiniのジオメトリは完成です。 最後にoutコンテキストに移動して、Labs Vertex Animation Texturesノードを作成します。 Modeを「Dynamic Remeshing(Fluid)」、Target Engineを「 Unreal Engine 」に設定します。 Input Geometryに先ほど作成した「VAT_OUT」を設定します。 RenderPassを「First Pass(Geometry, Lookup Table, Data/Material)」と「Second Pass(Animation Textures)」それぞれに設定して、「Render All」ボタン押下でマテリアルを生成します。 「data」「geo」「 tex 」フォルダにアウトプットが格納されていたらVATの生成は完了です。 4. Unreal Engine でVATを読み込み 今回は、 Unreal Engine のFirst Personテンプレートを用います。 3.にてHoudiniで作成したLabs Vertex Animation Texturesノードの以下リンクから取得したPluginフォルダを、UEプロジェクトフォルダに「Plugin」フォルダを作成した上でコピー&ペーストします。 UEプロジェクトを再起動して、正しくPluginが読み込まれていることを確認します。 3.で生成したファイルを Unreal Engine のContent Drawerにimportします。 HDR (.exr)のデータを選択して、Scripted Asset ActionsからSide FX Set VAT HDR Texturesを実行します。 同様にNon- HDR (.jpg)のデータに対して ide FX Set VAT Non HDR Texturesを実行します。 新規マテリアルを作成します。 ResultノードのNum Customized UVsに4を入力します。 任意のColorとMF_VAT_DynamicRemeshingノードを作成して、Resultノードに接続します。 上記マテリアルから、マテリアル インスタンス を生成します。 画像の通り、Houdini VATタブで、 FPS 、importしたテクスチャそれぞれ適用します。 また、importしたDataTableの値を参考に、Houdini VAT - InstancingタブでBound値を設定します。 設定元: 設定先: これでVATによるマテリアルは完成しました。 5. Niagara Systemでエフェクトを作成 新規 Niagara Effectを作成します。 今回はSimple Sprite Burstをベースにエフェクトを作成します。 Renderタブでデフォルトで入っているSprite Renderereを削除して、Mesh Rendererを追加します。 Meshesにimportしたfbx、Materialに4.で作成したマテリアル インスタンス を設定します。 Particle Spawnタブで、Lifetimeを1にします。 作成した Niagara SystemをSceneViewに ドラッグ&ドロップ して、エフェクトが再生されたらエフェクトは完了です。 6. BluePrintでエフェクト発生ロジックを組み込み 本記事ではVAT解説記事にフォーカスするためBPの詳細説明は割愛します。 FirstPersonプロジェクトでは、クリック押下で銃弾(Projectile)を発射するBluePrintが組み込まれているので、こちらの既存BPに対してエフェクトを追加します。 作成したネットワークはこちらです。 ProjectileのEvent Hit後、Projectileの位置に対してエフェクトを発生させています。 また、そのままだとProjectileが跳ね返ってしまうのでStop Movement Immediatelyノードで動きを止めつつ、Destroy Actorノードで削除しています。 また、Projectileが当たった壁に対して水しぶきを表現する為に、Destroy Actorの後に以下BPも追加しています。 別途Houdiniでシミュレーションして作成した以下マテリアル画像を用いて、Decalを発生させています。Houdiniからの画像Exportには、Labs Maps Bakerノードを用いました。 シミュレーション結果: ExportしたNormal: ExportしたOpacity: 完成イメージ 以下、 Unreal Engine での動作イメージです。 所感 今回はVAT(Vertex Animation Texture)という手法を用いて、Houdiniのシミュレーションを Unreal Engine に導入しました。 この手法は流体だけでなく、剛体の破壊シミュレーションや布などのソフトボディ、煙や爆発などあらゆるアニメーションに使用できます。また、UnrealEngineだけでなくUnityなど他のツールにも使用できる点も利便性が高いです。 Houdiniは非常に強力かつ精緻なシミュレーションが可能になる為、VATを利用することで ゲームエンジン などに組み込まれたツールよりも表現の幅の広がりが期待できます。 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 参考 https://vimeo.com/266717949 https://www.youtube.com/watch?v=BaKNjIC66_8 https://historia.co.jp/archives/21974/ 執筆: @yamashita.yuki 、レビュー: @wakamoto.ryosuke ( Shodo で執筆されました )
こんにちは!金融ソリューション事業部の山下です。 本記事では Houdini で作成したトゥーン調エフェクトを、VAT(Vertex Animation Texture)という手法を用いて Unreal Engine で利用する方法を紹介します。 Houdiniはプロシージャルにノードベースで3DCG制作が可能なDCCツールです。特にシミュレーションの機能が充実しており、特に映画/ゲーム業界等の VFX 領域で活用されております。 VAT(Vertex Animation Texture)とは 実施環境/ツール 実施手順 1. Houdiniでベースとなるジオメトリを作成 2. FLIP Solverでシミュレーションを実施 3. シミュレーション結果をメッシュに加工してVATとしてExport 4. Unreal EngineでVATを読み込み 5. Niagara Systemでエフェクトを作成 6. BluePrintでエフェクト発生ロジックを組み込み 完成イメージ 所感 参考 VAT(Vertex Animation Texture)とは VATとは文字通り、メッシュの各頂点(Vertex)に対応するアニメーション情報を、テクスチャ画像として マッピング したデータを用いて ゲームエンジン などで利用する手法です。 以下画像のように、横軸に頂点情報、縦軸を時系列(フレーム)情報として マッピング しています。これを用いて、アニメーションに必要な位置情報や回転情報を伝えることができます。 VATを用いるメリットは、クオリティとパフォーマンスです。 今回のようにHoudiniで作成したアニメーションを、 Unreal Engine やUnityなどで比較的低いパフォーマンス負荷で利用可能になります。その為、VATは主にゲーム業界などで活用されています。 こちらのような素晴らしい作例 もあるので、ぜひご覧いただけると幸いです。 VATの詳細については こちらのSidefx公式チュートリアル が詳しいので、ご興味ある方はご覧ください。 実施環境/ツール OS: Windows 11 pro GPU : NVIDIA GeForce RTX 3070Ti Laptop DCC:Houdini Indie version 19.5.534 Game Engine: Unreal Engine 5.1.1 実施手順 Houdiniで元となるジオメトリを作成 FLIP Solverでシミュレーションを実施 シミュレーション結果をメッシュに加工してVATとしてExport Unreal Engine でVATを読み込み Niagara Systemでエフェクトを作成 BluePrintでエフェクト発生ロジックを組み込み 1. Houdiniでベースとなるジオメトリを作成 シミュレーションを適用する為のソースを作成します。 ネットワークの全体像は以下のとおりです。主要な処理に絞って解説します。 最初に Sphere を作成します。 後続工程でノイズをかける為、Primitive Typeを「Polygon」、Frequencyを6に設定します。 Mountain(Attribute Noise)ノードでノイズを適用します。 好みの形になるようにNoise Value やNoise Patternを調整します。 Nullノードとして「SOURCE」と 命名 します。 ここまでのジオメトリデータを用いて、シミュレーションのソースとして扱います。 法線ベクトルを用いて、シミュレーションの初期速度値を定義していきます。 Normalノードで法線ベクトルを作成します。 Add Normals toは「Points」に設定します。 fluidsourceノードで、法線ベクトルからVolumeを作成します。 法線ベクトルデータをvelとして変換する為に、Velocity VolumesタブのSource Attributreを「N」と入力します。 速度が追加されました。 Node Infoを見ると、Voxelデータが確認できます。 Voxel数は9,660、Sizeは0.1。density以外に、速度データとしてvelが追加されていることを確認できます。 「VEL」という名称でNullノードを作成して、ソースジオメトリの作成は完了です。 2. FLIP Solverでシミュレーションを実施 1.で作成したVELノードを選択した状態で、Particle Fluidsタブの「FLIP Fluid from Object」を選択し、Enterを押下します。 AutoDopNetworkが作成されます。 DOPネットワーク内で、以下のシンプルなネットワークを作成します。 FLIP Objectノードで、ソースとなるParticleの情報を設定します。 Particleを細かくする為に、Particle Separationを0.05に設定します。 Initial DataタブのSOP Pathに、1.で生成したSOURCEを設定します。 Source Volumeノードを設定します。 Volume Pathに、1.で作成したVELノードのパスを設定します。 Volume OperationのVelocityプルダウン項目に「Add」を設定します。 SOP To DOP BindingsタブのVelocity Volumeに「vel」を設定します。 FLIP Solverノードに対して、FLIP ObjectノードとSource Volumeノードを接続します。こちらはデフォルトのままで構いません。 デフォルトで配置されているGravityノードなどは削除します。 以下のシミュレーションが完成しました。 3. シミュレーション結果をメッシュに加工してVATとしてExport 新たにGeometoryを作成して、以下のネットワークを構築します。名称はPrepare_Meshとします。 最初にDOP Import FieldsノードでDOPのシミュレーション結果をimportします。 Particle Fluid Surface ノードでParticleからメッシュを作成します。 メッシュを自然に消滅させる為に、FilteringタブのErodeにキーフレームを打ちます。 今回は25フレームを2、50フレームで4に設定することで、自然な消滅を表現します。 33フレーム時点: 40フレーム時点: 50フレーム時点: 初期のアニメーションは不要なので、Time Shiftノードを使用します。 Frameに「$F+10」とすることで初期10フレームを省略します。 ConvertノードでMeshからPolygonに変換します。 PolyReduceノードでPolygon数を削減します。 Number To Keepに2500を指定します。 Polygon数は58955から2599、15.55%に削減されました。 Attribute Blur ノードで各ポリゴンの位置を滑らかにします。 ポリゴン数の削減によるクオリティ低下を補う効果があります。 Blur 前: Blur 後: Normalノードで、Pointsに対して法線ベクトルを追加します。 レンダリング 時により滑らかな表面になります。 Labs Delete Small Partsノードで、微細なポリゴンを削除します。 UV UnwrapノードでUVを設定します。 Nullノードに「VAT_OUT」と 命名 して、Houdiniのジオメトリは完成です。 最後にoutコンテキストに移動して、Labs Vertex Animation Texturesノードを作成します。 Modeを「Dynamic Remeshing(Fluid)」、Target Engineを「 Unreal Engine 」に設定します。 Input Geometryに先ほど作成した「VAT_OUT」を設定します。 RenderPassを「First Pass(Geometry, Lookup Table, Data/Material)」と「Second Pass(Animation Textures)」それぞれに設定して、「Render All」ボタン押下でマテリアルを生成します。 「data」「geo」「 tex 」フォルダにアウトプットが格納されていたらVATの生成は完了です。 4. Unreal Engine でVATを読み込み 今回は、 Unreal Engine のFirst Personテンプレートを用います。 3.にてHoudiniで作成したLabs Vertex Animation Texturesノードの以下リンクから取得したPluginフォルダを、UEプロジェクトフォルダに「Plugin」フォルダを作成した上でコピー&ペーストします。 UEプロジェクトを再起動して、正しくPluginが読み込まれていることを確認します。 3.で生成したファイルを Unreal Engine のContent Drawerにimportします。 HDR (.exr)のデータを選択して、Scripted Asset ActionsからSide FX Set VAT HDR Texturesを実行します。 同様にNon- HDR (.jpg)のデータに対して ide FX Set VAT Non HDR Texturesを実行します。 新規マテリアルを作成します。 ResultノードのNum Customized UVsに4を入力します。 任意のColorとMF_VAT_DynamicRemeshingノードを作成して、Resultノードに接続します。 上記マテリアルから、マテリアル インスタンス を生成します。 画像の通り、Houdini VATタブで、 FPS 、importしたテクスチャそれぞれ適用します。 また、importしたDataTableの値を参考に、Houdini VAT - InstancingタブでBound値を設定します。 設定元: 設定先: これでVATによるマテリアルは完成しました。 5. Niagara Systemでエフェクトを作成 新規 Niagara Effectを作成します。 今回はSimple Sprite Burstをベースにエフェクトを作成します。 Renderタブでデフォルトで入っているSprite Renderereを削除して、Mesh Rendererを追加します。 Meshesにimportしたfbx、Materialに4.で作成したマテリアル インスタンス を設定します。 Particle Spawnタブで、Lifetimeを1にします。 作成した Niagara SystemをSceneViewに ドラッグ&ドロップ して、エフェクトが再生されたらエフェクトは完了です。 6. BluePrintでエフェクト発生ロジックを組み込み 本記事ではVAT解説記事にフォーカスするためBPの詳細説明は割愛します。 FirstPersonプロジェクトでは、クリック押下で銃弾(Projectile)を発射するBluePrintが組み込まれているので、こちらの既存BPに対してエフェクトを追加します。 作成したネットワークはこちらです。 ProjectileのEvent Hit後、Projectileの位置に対してエフェクトを発生させています。 また、そのままだとProjectileが跳ね返ってしまうのでStop Movement Immediatelyノードで動きを止めつつ、Destroy Actorノードで削除しています。 また、Projectileが当たった壁に対して水しぶきを表現する為に、Destroy Actorの後に以下BPも追加しています。 別途Houdiniでシミュレーションして作成した以下マテリアル画像を用いて、Decalを発生させています。Houdiniからの画像Exportには、Labs Maps Bakerノードを用いました。 シミュレーション結果: ExportしたNormal: ExportしたOpacity: 完成イメージ 以下、 Unreal Engine での動作イメージです。 所感 今回はVAT(Vertex Animation Texture)という手法を用いて、Houdiniのシミュレーションを Unreal Engine に導入しました。 この手法は流体だけでなく、剛体の破壊シミュレーションや布などのソフトボディ、煙や爆発などあらゆるアニメーションに使用できます。また、UnrealEngineだけでなくUnityなど他のツールにも使用できる点も利便性が高いです。 Houdiniは非常に強力かつ精緻なシミュレーションが可能になる為、VATを利用することで ゲームエンジン などに組み込まれたツールよりも表現の幅の広がりが期待できます。 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 参考 https://vimeo.com/266717949 https://www.youtube.com/watch?v=BaKNjIC66_8 https://historia.co.jp/archives/21974/ 執筆: @yamashita.yuki 、レビュー: @wakamoto.ryosuke ( Shodo で執筆されました )
XI本部 オープン イノベーション ラボの飯田です。 最近、ChatGPTをはじめとした生成AIが注目されています。 その中で、 Gigazine のニュースで以下の話を目にしました。 gigazine.net 上記の記事に感化され、心理学でChatGPTのようなものが、どのように扱われているのかを調べています(私は大学で心理学専攻だったこともあり)。その中で、心理学系論文の プレプリント が掲載されている PsyArXiv で、GPT-3の振る舞いを 認知心理学 の観点で分析した論文を見つけました。 生成系の振る舞いを理解する上でも参考になりそうだし、ISIDでも取り組んでいるロボット技術やLabratory Automationとの接合点を見つけた気がしましたので、ISIDでの取り組みを紹介しつつ、妄想したいと思います。 Binz, M., & Schulz, E. (2022, June 21). Using cognitive psychology to understand GPT-3. https://doi.org/10.31234/osf.io/6dfgk ※ PNASに査読付き論文も公開されている ようですが、有料なので、今回は無料のPsyArXivをよんでいます。 ※念押しになりますが、以下、引用するBinz&Schulz(2022)は「ChatGPT」ではなく、「GPT-3」にて実験を行っています。 認知心理学の観点からみたGPT-3 Binz&Schulz(2022)の概要 Binz&Schulz(2022)の実験イメージ 意思決定:ヒューリスティックとバイアス(例:リンダ問題) 熟慮:認知反射テスト 因果推論:ブリケット探知器 情報の探索:多腕バンディット課題 大規模言語モデルとロボット技術・Labratory Automationとの接合の可能性 最後に 認知心理学 の観点からみたGPT-3 Binz&Schulz(2022)の概要 GPT-3に 認知心理学 で使われる課題を与え、その解答からGPT-3の特性を考察 4カテゴリ(1.意思決定 / 2.情報探索 / 3.熟慮 / 4.因果推論) についての実験を行った GPT-3は多くの課題に正解し、バンディット課題でも良い成績を残し、モデルベース 強化学習 を行っている可能性を示した。これらの結果は、GPT-3が単なる確率的なオウム返しではないことを示している。 一方で、人間の認知機能で重要な"統制的探査”や"因果推論”の能力を持っていないことも推察された これは人間とGPT-3の間で、世界についての知識習得・学習方法が違うことによって説明できる 人間は、他の人とつながり、質問をし、積極的に環境と関わることで学習する GPT-3などの大規模 言語モデル は、受動的に多くのテキストを与えられ、次に来る単語を予測することで学習する 人間が持つ認知機能の複雑さにより近づくためには、テキストを受動的に学習するだけではなく、 積極的に世界とインタ ラク ションすることが重要 と指摘している Binz&Schulz(2022)の実験イメージ 概要だけでは、イメージをつかみにくいと思いますので、簡単にBinz&Schulz(2022)がどのような実験をしたのか触れたいと思います。 意思決定: ヒューリスティック とバイアス(例:リンダ問題) 以下は、リンダのプロフィールです。 現在のリンダについて推測する場合、(1)と(2)のどちらの可能性が高いと思いますか? リンダは31歳、独身で、積極的に発言する非常に聡明です。 大学では哲学を専攻し、学生時代には差別や社会正義の問題に関心を持っていました。また、反核デモに参加していました。 1. リンダは銀行の出納係である。 2. リンダは銀行の出納係であり、フェミニスト運動の活動家である。  上記のような質問をされると、2は1の部分集合なので、確率的には1の方が間違いなく高いにもかかわらず、多くの人は2を選択する傾向にあります。 そのような「一般的な状況よりも、特殊な状況の方が起こりやすい」と誤判断することは"合接の誤謬"や"連言錯誤"と呼ばれます。 ステレオタイプ に合致した方を過大に評価しやすい意思決定プロセス(代表性 ヒューリスティクス )とされます。 →GPT-3は人と同じように、2番目の選択肢を選び、合接の誤謬に陥りました ※神谷先生がChatGPTでも試していらっしゃいます リンダ問題の答えを聞いて怒り出さないのが人間らしくない pic.twitter.com/HxxdPUbFOT — Yuki Kamitani (@ykamit) 2022年12月5日 熟慮:認知反射テスト 誤った解答がパッと浮かびやすい問題を課して、直観型か熟慮型かを見分けるようなテストです。 例)5台の機械を5分間動かすと、製品が5つできる。100台の機械で100個の製品を作るには、何分かかるか。 - パッと浮かびやすい解答:100分 - 正解:5分 →GPT-3は、多くの人間が選んでしまう、パッと浮かびやすい解答を行い不正解となりました 因果推論:ブリケット探知器 ブリケット探知器とは、以下のような装置であり、子供の因果推論能力を推定するテストです ある箱の上に、いろいろな色や形をした積み木のようなものを置く。 特定の積み木を特定の置き方で置いたとき、箱が光る。 →GPT-3は、人間と同じように、因果を見出しました 情報の探索:多腕バンディット課題 利益を最大化するための「活用」と「探索」の2種類の情報探索方法を適切に使い分けることができるか?という課題 活用:利益がどれぐらい得られるか過去に経験した手段の活用 探索:利益をさらに得られるかもしれない未知の手段の探索 このような質問が提示されて回答をしていくイメージです。 →GPT-3は人間と同等、それ以上のスコアを収めることができた。 しかし、探索方法の戦略を見ると、ランダム探索を主に行っており、統制的探査は見られなかった。 Binz&Schulz(2022)では、他にも詳しく行っていますので、気になる方は論文をお読みください。 大規模 言語モデル とロボット技術・Labratory Automationとの接合の可能性 Binz&Schulz(2022)では、「大規模 言語モデル はテキストを受動的に学習しているだけであるため、因果推論等は弱い」という指摘がありました。 その世界に介入(試行錯誤)して結果を得る取り組みとして、ロボット技術やLabratory Automation等があるのではないかと思います。 ISIDイノラボでは下記のように、ロボット技術を身近なものに活かす取り組みを色々と行っております。 ロボット技術を上手く使うことにより、ソフトウェアだけでは実現できない世界とのインタ ラク ション・フィードバックを基にした学習が可能となり、大規模 言語モデル の一層の進化が期待できるのではないでしょうか? www.isid.co.jp www.isid.co.jp また、Labratory Automationとは、下の動画のようなロボットによる科学実験の自動化技術です。 下記のNatureの動画のように、人間を介在せず、実験・試行錯誤をできます。 ※ISIDイノラボでもLabratory Automationの研究開発に取り組んでおり、その内容もいずれお伝えできればと思います。 大規模 言語モデル とロボット技術やLabratory Automationを上手く組み合わせることができたら、この論文でGPT-3の弱点と言われる因果推論もできるようになってくるのかもしれないと感じました。 認知心理学 では、身体化認知(embodied cognition)という概念があります。身体化認知では、人間の認知機能や概念知識を構築するにおいて、身体(感覚や身体的経験)が必須であるという立場です。 psychmuseum.jp ※上記P18にある通り、実験の追試に失敗したりなど、再現性問題が指摘されていたりしますが・・・ また、静的なデー タセット によるト レーニン グから学んだ表現よりも、世界との相互作用を通じて学習した表現の方が強力であるとして Embodied AIが提唱されており 、CVPRでは Embodied AIのワークショップ も開催されています。 大規模 言語モデル においても、より進化させていくにあたっては、身体化認知のような 認知科学 の知見も活かされてくるのだろうなと感じました。 最後に ChatGPTでも簡単にできるテストを試してみましたので、結果を載せておきます。 リンダ問題 最初やってみたら、実験の決まり文句だとバレました 少し修正したら、誤魔化すことができて、連言錯誤に陥りました(改変が妥当でない可能性もありますが) 認知反射テスト キレイに間違えてくれました 情報探索(適切な質問を選ぶことができるのか?) 正解しました 最後に、私たちは一緒に働いてくれる仲間を募集しています! デジタル技術を社会課題解決につなげるようなプロジェクトを推進していきたいプロジェクトマネージャーやエンジニアを募集しています。 ぜひご応募ください! ソリューションアーキテクト スマートシティ導入コンサルタント/スマートシティ戦略コンサルタント 執筆: @iida.michitaka 、レビュー: @yamada.y ( Shodo で執筆されました )