TECH PLAY

電通総研

電通総研 の技術ブログ

822

こんにちは、金融ソリューション事業部の若本です。 先日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 で執筆されました )
アバター
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 で執筆されました )
アバター
こんにちは。X(クロス) イノベーション 本部 ソフトウェアデザインセンター の山下です。 今回はユーザーに合わせてオートスケールする GitHub ActionsのRunnerについて紹介しようと思います。 課題と目的 公式の推奨している方法について 構築の手順 事前準備 terraformの実行 terraformファイルの作成 terraformの実行 GitHub Appにhookの設定を追加 実際に利用する場合 まとめ 課題と目的 GitHub Actionsを使ってCIを実施するのは一般的になってきています。 ISIDでも GitHub Actionsを活用してCIを実施しています。 しかし、 GitHub 社が提供しているrunners( GitHub -hosted runners)では困る場合があります。「 GitHub Actionsでオンプレミス環境のCI/CDを実行する方法 」の記事では、オンプレミス環境のような外部ネットワークに接続できないような環境で GitHub Actionsを利用したCIを実施する場合に、self-hosted runnersを紹介しました。 オンプレ環境で実行できないこと以外にも、 GitHub Actionsの標準環境にはいくつか不満があります。 CIの待ち時間が長い CIに使うマシンの性能を調整したい CIの費用が高い といった問題です。 GitHub -hosted runnersでは、 GitHub 社が提供しているインフラに依存しています。このため、 GitHub 側の調子が悪いときにCI開始されるまでの待ち時間が長くなったりします。 また、CIの実行に使うマシンも GitHub 社が決めたものの範囲で選ぶことになってしまいます。 以前は、選択の余地がなかったのですが、Larger Runnersがbeta版ですが公開されて状況は改善しつつあります。Larger Runnersに関する公式の記事は こちら です。 また、 GitHub Actionsの料金は Linux の場合、1分当たり$0.008 となっていて安くはないです。 単純計算すると1時間あたり $0.48 になってしまい、 AWS だとt3.2xlargeを借りることが出来てしまいます。 以下のドキュメントによると、 GitHub 標準のrunnerの性能は、2コア、7GBメモリのマシンのようなので若干コスト高になってしまいます。 https://docs.github.com/ja/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources self-hosted runnerを導入することで、自前の環境を構築すれば待ち時間や性能の問題は解決します。しかし、高性能なマシンをずっと起動しているとコストが高くなってしまいます。 必要な時、必要な分だけEC2を起動し GitHub Actionsのself-hosted runnersを動作できれば、コストパフォーマンスに優れるself-hosted runners環境を構築できます。 今回はその環境(Autoscale self-hosted runners)について解説します。 公式の推奨している方法について GitHub の 公式の資料 にオートスケールするself-hosted runnersについての記載があります。 基本的に、この手順に従って構築すれば問題ないです。公式では以下の2種類の方法が提供されています。 actions-runner-controller/actions-runner-controller - GitHub Actions のセルフホステッド ランナー用の Kubernetes コントローラー。 philips-labs/terraform-aws-github-runner - AWS 上のスケーラブルな GitHub Actions ランナー用の Terraform モジュール。 actions-runner-controller/actions-runner-controller は kubernetes 上にself-hosted runnersを構築する物になっています。既に kubernetes を運用している場合にはこちらを導入するのが良いですが、 GitHub Actionsのためだけに kubernetes を運用するのは運用負荷を考えると難しいので今回は採用しませんでした。 philips-labs/terraform-aws-github-runner は、 AWS のAutoScaling Groupを利用して self-hosted runners環境を構築するものとなっています。構築はterraformを利用することになります。今回はこちらを使って環境構築を行います。 構築の手順 それでは早速構築の手順について解説します。 GitHub Appの設定と terraform の実行と両方を行っていく必要があるので順番に気を付けて実施してください。 事前準備 環境構築に際していくつか事前に準備しておく物があります。 terraformのインストール GitHub Appの作成 GitHub AppのClientID GitHub App用の 秘密鍵 terraformは各自インストールする必要があります。 公式サイト から適宜インストールしておいてください。 terraform-aws-github-runner のマニュアルに記載がありますが、 GitHub 上で GitHub Appを作成する必要があります。いくつか注意が必要です。 まず、最初に作成する際は、WebhookをActiveにしないで作成します。 今回は特定のプロジェクトで利用するような事を想定し、 GitHub Appに与える権限をRepositoryレベルで設定します。以下のように、Actions、Checks、MetadataをRead-onlyにします。それに加えて、AdministrationをRead & writeとしました。 terraformの実行 terraformファイルの作成 今回は以下のような2つのtfファイルを作成して実行しました。片方が VPC を作成するもの、片方が今回のself-hosted runner用の物です。 // vpc.tf module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "3.11.2" name = "vpc-${local.environment}" cidr = "10.0.0.0/16" azs = ["${local.aws_region}a", "${local.aws_region}c", "${local.aws_region}d"] private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"] enable_dns_hostnames = true enable_nat_gateway = true map_public_ip_on_launch = false single_nat_gateway = true tags = { Environment = local.environment } // main.tf locals { version = "v1.8.0" environment = "default" aws_region = "ap-northeast-1" } provider "aws" { region = "ap-northeast-1" } variable "github_app_key_base64" {} variable "github_app_id" {} resource "random_id" "random" { byte_length = 20 } data "aws_caller_identity" "current" {} module "runners" { source = "philips-labs/github-runner/aws" version = "1.8.0" create_service_linked_role_spot = false aws_region = local.aws_region vpc_id = module.vpc.vpc_id subnet_ids = module.vpc.private_subnets prefix = local.environment tags = { costcenter = "p7-ci-template" Project = "AutoScaleTest" } github_app = { key_base64 = var.github_app_key_base64 id = var.github_app_id webhook_secret = random_id.random.hex } webhook_lambda_zip = "webhook.zip" runner_binaries_syncer_lambda_zip = "runner-binaries-syncer.zip" runners_lambda_zip = "runners.zip" enable_organization_runners = false # ランナーのラベル # github actionsの設定ファイルで指定するラベルとなる runner_extra_labels = "default,<CIで指定するラベル>" # enable access to the runners via SSM enable_ssm_on_runners = true instance_types = ["m5.large", "c5.large"] # override delay of events in seconds delay_webhook_event = 5 runners_maximum_count = 1 # set up a fifo queue to remain order fifo_build_queue = true # override scaling down scale_down_schedule_expression = "cron(* * * * ? *)" } また、変数の設定ファイル(terraform.tfvars)も作成します。 以下のような内容です。 秘密鍵 は base64 に変換することに注意してください。 github_app_id = "<<github appのid>>" github_app_key_base64 = <<EOF <<取得したgithub appの秘密鍵をbase64エンコードしたもの>> EOF terraformの実行 ここまで準備すれば、あとは terraform を実行するだけです terraform init terraform apply すると、 AWS 上に環境が構築されます。 GitHub Appにhookの設定を追加 先ほど、 terraform apply をした出力に GitHub appで利用するwebhookのURLが含まれています。 terraform output -json を実行しても取得できます。 以下のような出力が得られます。 { "runners": { "sensitive": false, "type": [ "object", { "lambda_syncer_name": "string" } ], "value": { "lambda_syncer_name": "default-syncer" } }, "webhook_endpoint": { "sensitive": false, "type": "string", "value": "<webhookのURL>" }, "webhook_secret": { "sensitive": true, "type": "string", "value": "<secret>" } } これらの <webhookのURL> 、 <secret> の内容が必要です。 この値を、 GitHub Appのwebhookのendpointとsecretとして登録します。画面では以下のようになります。 そして、eventのサブスクライブが必要なので、Permission & eventsから workflow job にチェックを入れてください。これで設定完了です。 実際に利用する場合 GitHub Actionsを設定する yaml ファイルで runs-on : [ self-hosted, default, <設定したラベル> ] と記載しておけば、実行時に自動的に インスタンス の起動などが行われてその インスタンス 上で実行されます。 まとめ 今回は、オートスケールする GitHub self-hosted runnersの構築手順について紹介しました。 これを使ってどんどんCIを回していきたいですね。 私たちは同じチームで働いてくれる仲間を探しています。今回のエントリで紹介したような仕事に興味のある方、ご応募お待ちしています。 - ソリューションアーキテクト 執筆: @yamashita.tsuyoshi 、レビュー: @sato.taichi ( Shodo で執筆されました )
アバター
こんにちは。X(クロス) イノベーション 本部 ソフトウェアデザインセンター の山下です。 今回はユーザーに合わせてオートスケールする GitHub ActionsのRunnerについて紹介しようと思います。 課題と目的 公式の推奨している方法について 構築の手順 事前準備 terraformの実行 terraformファイルの作成 terraformの実行 GitHub Appにhookの設定を追加 実際に利用する場合 まとめ 課題と目的 GitHub Actionsを使ってCIを実施するのは一般的になってきています。 ISIDでも GitHub Actionsを活用してCIを実施しています。 しかし、 GitHub 社が提供しているrunners( GitHub -hosted runners)では困る場合があります。「 GitHub Actionsでオンプレミス環境のCI/CDを実行する方法 」の記事では、オンプレミス環境のような外部ネットワークに接続できないような環境で GitHub Actionsを利用したCIを実施する場合に、self-hosted runnersを紹介しました。 オンプレ環境で実行できないこと以外にも、 GitHub Actionsの標準環境にはいくつか不満があります。 CIの待ち時間が長い CIに使うマシンの性能を調整したい CIの費用が高い といった問題です。 GitHub -hosted runnersでは、 GitHub 社が提供しているインフラに依存しています。このため、 GitHub 側の調子が悪いときにCI開始されるまでの待ち時間が長くなったりします。 また、CIの実行に使うマシンも GitHub 社が決めたものの範囲で選ぶことになってしまいます。 以前は、選択の余地がなかったのですが、Larger Runnersがbeta版ですが公開されて状況は改善しつつあります。Larger Runnersに関する公式の記事は こちら です。 また、 GitHub Actionsの料金は Linux の場合、1分当たり$0.008 となっていて安くはないです。 単純計算すると1時間あたり $0.48 になってしまい、 AWS だとt3.2xlargeを借りることが出来てしまいます。 以下のドキュメントによると、 GitHub 標準のrunnerの性能は、2コア、7GBメモリのマシンのようなので若干コスト高になってしまいます。 https://docs.github.com/ja/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources self-hosted runnerを導入することで、自前の環境を構築すれば待ち時間や性能の問題は解決します。しかし、高性能なマシンをずっと起動しているとコストが高くなってしまいます。 必要な時、必要な分だけEC2を起動し GitHub Actionsのself-hosted runnersを動作できれば、コストパフォーマンスに優れるself-hosted runners環境を構築できます。 今回はその環境(Autoscale self-hosted runners)について解説します。 公式の推奨している方法について GitHub の 公式の資料 にオートスケールするself-hosted runnersについての記載があります。 基本的に、この手順に従って構築すれば問題ないです。公式では以下の2種類の方法が提供されています。 actions-runner-controller/actions-runner-controller - GitHub Actions のセルフホステッド ランナー用の Kubernetes コントローラー。 philips-labs/terraform-aws-github-runner - AWS 上のスケーラブルな GitHub Actions ランナー用の Terraform モジュール。 actions-runner-controller/actions-runner-controller は kubernetes 上にself-hosted runnersを構築する物になっています。既に kubernetes を運用している場合にはこちらを導入するのが良いですが、 GitHub Actionsのためだけに kubernetes を運用するのは運用負荷を考えると難しいので今回は採用しませんでした。 philips-labs/terraform-aws-github-runner は、 AWS のAutoScaling Groupを利用して self-hosted runners環境を構築するものとなっています。構築はterraformを利用することになります。今回はこちらを使って環境構築を行います。 構築の手順 それでは早速構築の手順について解説します。 GitHub Appの設定と terraform の実行と両方を行っていく必要があるので順番に気を付けて実施してください。 事前準備 環境構築に際していくつか事前に準備しておく物があります。 terraformのインストール GitHub Appの作成 GitHub AppのClientID GitHub App用の 秘密鍵 terraformは各自インストールする必要があります。 公式サイト から適宜インストールしておいてください。 terraform-aws-github-runner のマニュアルに記載がありますが、 GitHub 上で GitHub Appを作成する必要があります。いくつか注意が必要です。 まず、最初に作成する際は、WebhookをActiveにしないで作成します。 今回は特定のプロジェクトで利用するような事を想定し、 GitHub Appに与える権限をRepositoryレベルで設定します。以下のように、Actions、Checks、MetadataをRead-onlyにします。それに加えて、AdministrationをRead & writeとしました。 terraformの実行 terraformファイルの作成 今回は以下のような2つのtfファイルを作成して実行しました。片方が VPC を作成するもの、片方が今回のself-hosted runner用の物です。 // vpc.tf module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "3.11.2" name = "vpc-${local.environment}" cidr = "10.0.0.0/16" azs = ["${local.aws_region}a", "${local.aws_region}c", "${local.aws_region}d"] private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"] enable_dns_hostnames = true enable_nat_gateway = true map_public_ip_on_launch = false single_nat_gateway = true tags = { Environment = local.environment } // main.tf locals { version = "v1.8.0" environment = "default" aws_region = "ap-northeast-1" } provider "aws" { region = "ap-northeast-1" } variable "github_app_key_base64" {} variable "github_app_id" {} resource "random_id" "random" { byte_length = 20 } data "aws_caller_identity" "current" {} module "runners" { source = "philips-labs/github-runner/aws" version = "1.8.0" create_service_linked_role_spot = false aws_region = local.aws_region vpc_id = module.vpc.vpc_id subnet_ids = module.vpc.private_subnets prefix = local.environment tags = { costcenter = "p7-ci-template" Project = "AutoScaleTest" } github_app = { key_base64 = var.github_app_key_base64 id = var.github_app_id webhook_secret = random_id.random.hex } webhook_lambda_zip = "webhook.zip" runner_binaries_syncer_lambda_zip = "runner-binaries-syncer.zip" runners_lambda_zip = "runners.zip" enable_organization_runners = false # ランナーのラベル # github actionsの設定ファイルで指定するラベルとなる runner_extra_labels = "default,<CIで指定するラベル>" # enable access to the runners via SSM enable_ssm_on_runners = true instance_types = ["m5.large", "c5.large"] # override delay of events in seconds delay_webhook_event = 5 runners_maximum_count = 1 # set up a fifo queue to remain order fifo_build_queue = true # override scaling down scale_down_schedule_expression = "cron(* * * * ? *)" } また、変数の設定ファイル(terraform.tfvars)も作成します。 以下のような内容です。 秘密鍵 は base64 に変換することに注意してください。 github_app_id = "<<github appのid>>" github_app_key_base64 = <<EOF <<取得したgithub appの秘密鍵をbase64エンコードしたもの>> EOF terraformの実行 ここまで準備すれば、あとは terraform を実行するだけです terraform init terraform apply すると、 AWS 上に環境が構築されます。 GitHub Appにhookの設定を追加 先ほど、 terraform apply をした出力に GitHub appで利用するwebhookのURLが含まれています。 terraform output -json を実行しても取得できます。 以下のような出力が得られます。 { "runners": { "sensitive": false, "type": [ "object", { "lambda_syncer_name": "string" } ], "value": { "lambda_syncer_name": "default-syncer" } }, "webhook_endpoint": { "sensitive": false, "type": "string", "value": "<webhookのURL>" }, "webhook_secret": { "sensitive": true, "type": "string", "value": "<secret>" } } これらの <webhookのURL> 、 <secret> の内容が必要です。 この値を、 GitHub Appのwebhookのendpointとsecretとして登録します。画面では以下のようになります。 そして、eventのサブスクライブが必要なので、Permission & eventsから workflow job にチェックを入れてください。これで設定完了です。 実際に利用する場合 GitHub Actionsを設定する yaml ファイルで runs-on : [ self-hosted, default, <設定したラベル> ] と記載しておけば、実行時に自動的に インスタンス の起動などが行われてその インスタンス 上で実行されます。 まとめ 今回は、オートスケールする GitHub self-hosted runnersの構築手順について紹介しました。 これを使ってどんどんCIを回していきたいですね。 私たちは同じチームで働いてくれる仲間を探しています。今回のエントリで紹介したような仕事に興味のある方、ご応募お待ちしています。 - ソリューションアーキテクト 執筆: @yamashita.tsuyoshi 、レビュー: @sato.taichi ( Shodo で執筆されました )
アバター
こんにちは、 GMS 事業部グループ経営 コンサルティング 第1ユニットの安田しおりです。 この記事では、ISIDに興味を持ってくださっている就活生の皆さんに向けて3年目(2021年入社)の私の働き方を紹介したいと思います。 仕事内容について まずは、私がどんな仕事をしているのか仕事内容を簡単に紹介します。 私が所属するグループ経営 コンサルティング 第1ユニット(GMC1ユニット)では STRAVIS という 連結会計 ソリューションを扱っています。私はSTRAVISの コンサルタント として様々な業務に関わっています。ここでは、主な3つの業務についてご紹介します。 1. 導入プロジェクト 導入プロジェクトでは、STRAVISをお客様にご利用いただくためのシステム導入作業として、要件定義や設定、検証、トライアル支援等を実施します。要件定義のフェーズではお客様から業務内容を ヒアリ ングし、必要な機能や設定内容を検討します。設定フェーズでは、要件定義フェーズで整理した内容に基づいて実際にSTRAVISを設定します。基本的にSTRAVISは導入時にコーディングの必要がなく、画面操作によって基本情報や処理の設定を行うことができます。検証フェーズでは、設定内容を確認するためのテストケースを作成し、テストを実施します。トライアルフェーズではシステムの試験稼働をサポートします。 2. セールス支援の案件 新しく 連結会計 システムの導入を検討しているお客様への提案や、既存のSTRAVISユーザーへの追加提案活動を行います。 セールス支援での コンサルタント の役割は、お客様にSTRAVISの特徴やSTRAVISを導入するメリットを理解していただくことです。そのために、お客様の前でSTRAVISを操作しながら機能紹介をしたり、実際にお客様にシステムを触ってもらいながら操作をレクチャーしたりします。また、具体的な提案内容を提案書にまとめ、プレゼンします。お客様へのプレゼンや機能紹介は「上手く伝えられるか」と緊張する瞬間ではありますが、提案後にお客様からSTRAVISに対して良い評価のコメントをいただけるととてもやりがいを感じます。 3. BPO ( アウトソーシング サービス) ISIDでは、STRAVIS導入後のお客様に対してSTRAVIS関連の連結決算業務の一部を支援する アウトソーシング サービスを提供しています。私は BPO 案件の中で、STRAVISの設定のメンテナンスを行ったり、データ確認や仕訳作成の一部作業を担当したりしています。実際の決算業務の中でSTRAVISを使うことができるため、自分の成長に大きくつながったプロジェクトだと考えています。 上記の3つの案件以外にも、STRAVISオンライン セミ ナーの講師を務めるなど幅広く活動しています。 1週間の流れ  仕事内容の紹介で複数案件に参加していると書きましたが、どのような1週間を過ごしているかスケジュールを紹介します。月によって案件の内容は変わりますが、1週間の中でセールス支援がメインの日もあれば導入作業や BPO の作業がメインの日もあります。お客様とのミーティングの予定も多く、常に複数のお客様と関わりがあります。他にも部内の活動として、グループ会や1on1が週に1回程度あります。グループ会では案件状況の報告や、メンバ間でのノウハウ共有を行います。 また、部長やグルプマネージャーと1対1で会話する機会として週に1回15分程度1on1を実施しています。1on1を定期的に実施していることで、業務の中での相談などもより気軽にできていると思います。 終業後は、同期に会ったり、美容院に行ったりとプライベートの時間を確保して気持ちをリフレッシュしています。 GMC1ユニットでの働き方の特長 次に、私が考える「この部署での働き方の特長」についてです。私がGMC1ユニットで働く中で、自分に合っていると感じたポイント2つについて説明します。 私が考える特長の1つめは「幅広さ」です。 私は入社3年目ですが、様々な案件に関わってきました。複数の案件に同時に関わることが多いため、次々と新しいことに挑戦できますし、 スキルアップ できる機会も豊富にあります。そして単に新しいことに挑戦する機会が多いだけでなく、性質の異なる案件を経験することで様々な角度から知識やスキルを習得できる点もメリットに感じています。例えば、導入プロジェクトを経験していたことでセールス支援の時に具体的な導入事例を紹介できた、というように幅広く経験して良かったなと思うことが多いです。このように得た知識を発揮できる場が多いと自分の成長を実感でき、モチベーションの維持にも繋がっています。 また私の場合、この部署での幅広い業務経験が「特にどのスキルを伸ばしていきたいか」「どういう事に特化した人材を目指すのか」という選択肢を拡げることに繋がりました。入社直後はやりたい事のイメージが掴めていませんでしたが、今では選択肢の中から将来のキャリアをイメージできています。 このような理由から、GMC1ユニットの「幅広さ」が私にとってマッチしていたと考えています。 2つ目の特長は、若手のうちからお客様との関わりが多いという点です。 「新規提案中のお客様」「一緒にSTRAVISの導入を行っているお客様」「 BPO で関わるSTRAVISユーザー」など様々な立場のお客様とプロジェクトを通して接することができます。お客様の反応を直接確認できるので、良い反応を貰えると嬉しいですし、それがモチベーションにもつながっています。逆に、お客様の反応から自分の改善するべき点に気づく場合もあります。例えばお客様からの指摘や質問に一人では対応できず、どの部分の理解が不足しているのか気付くこともあります。お客様からの評価と気づきをあらゆる角度から得ることが出来るという点が顧客との関わりが多いメリットだと思っています。 また若手のうちから、セールス支援でも導入プロジェクトでもメインスピーカーとして参加できるので「プレゼン力を身につけたい」「社外でも通用するビジネススキルを身につけたい」といった人にも向いていると思います。学生時代、私は人前で話すのがとても緊張するタイプでしたが、今ではスピーカーとして会議に参加することも徐々に楽しめるようになってきました。 入る前のイメージと入った後感じたこと 私は学生時代、ITとも会計とも無縁の専攻でした。そのため、配属前は自分に会計やシステムの事が理解できるのか、チームのメンバの一員として役に立つことが出来るのか不安を感じていました。また、「若手のうちから挑戦できる」=「放置される」なのではないかという不安もありました。 こんな風に不安を感じながら配属された私ですが、GMC1ユニットでは一人一人のレベルにあった新人教育がされ、先輩からのサポートも手厚いため不安なく働くことができました。具体的に、私が配属後一番初めに行ったのは「STRAVISを知る」事でした。GMC1ユニットではユーザー向けのeラーニングや セミ ナーを多数用意しているので、それらを受講して勉強することができます。さらに導入案件やセールス案件に参加して、実際のプロジェクトの流れや会計の知識、STRAVISの機能や操作について学ぶこともしました。 私のように、入社前・配属前の段階で会計やITの学習経験がない場合でも不安に感じる必要はないです。GMC1ユニットでは自分のスキルに合わせて学んでいくことができます。もし、少しでも興味を持ってくださった方はまずはISIDやSTRAVISを知ることから始めていただけると嬉しいです。 最後に 就活生の皆さんの中には私のようにIT系の専攻ではないけれどISIDに興味を持ってくださっている方もいるかと思います。 GMS 事業部GMC1ユニットでは既にITや会計に詳しいという方はもちろんですが、今はまだ興味を持ち始めた段階だという方も大歓迎です。私の働き方紹介が皆さんの将来を考える材料になればうれしいなと思います。 以下採用ページへのリンクです。興味を持ってくださった方は、ぜひご応募ください。 www.isid.co.jp 執筆: @yasuda.shiori 、レビュー: Ishizawa Kento (@kent) ( Shodo で執筆されました )
アバター
こんにちは、 GMS 事業部グループ経営 コンサルティング 第1ユニットの安田しおりです。 この記事では、ISIDに興味を持ってくださっている就活生の皆さんに向けて3年目(2021年入社)の私の働き方を紹介したいと思います。 仕事内容について まずは、私がどんな仕事をしているのか仕事内容を簡単に紹介します。 私が所属するグループ経営 コンサルティング 第1ユニット(GMC1ユニット)では STRAVIS という 連結会計 ソリューションを扱っています。私はSTRAVISの コンサルタント として様々な業務に関わっています。ここでは、主な3つの業務についてご紹介します。 1. 導入プロジェクト 導入プロジェクトでは、STRAVISをお客様にご利用いただくためのシステム導入作業として、要件定義や設定、検証、トライアル支援等を実施します。要件定義のフェーズではお客様から業務内容を ヒアリ ングし、必要な機能や設定内容を検討します。設定フェーズでは、要件定義フェーズで整理した内容に基づいて実際にSTRAVISを設定します。基本的にSTRAVISは導入時にコーディングの必要がなく、画面操作によって基本情報や処理の設定を行うことができます。検証フェーズでは、設定内容を確認するためのテストケースを作成し、テストを実施します。トライアルフェーズではシステムの試験稼働をサポートします。 2. セールス支援の案件 新しく 連結会計 システムの導入を検討しているお客様への提案や、既存のSTRAVISユーザーへの追加提案活動を行います。 セールス支援での コンサルタント の役割は、お客様にSTRAVISの特徴やSTRAVISを導入するメリットを理解していただくことです。そのために、お客様の前でSTRAVISを操作しながら機能紹介をしたり、実際にお客様にシステムを触ってもらいながら操作をレクチャーしたりします。また、具体的な提案内容を提案書にまとめ、プレゼンします。お客様へのプレゼンや機能紹介は「上手く伝えられるか」と緊張する瞬間ではありますが、提案後にお客様からSTRAVISに対して良い評価のコメントをいただけるととてもやりがいを感じます。 3. BPO ( アウトソーシング サービス) ISIDでは、STRAVIS導入後のお客様に対してSTRAVIS関連の連結決算業務の一部を支援する アウトソーシング サービスを提供しています。私は BPO 案件の中で、STRAVISの設定のメンテナンスを行ったり、データ確認や仕訳作成の一部作業を担当したりしています。実際の決算業務の中でSTRAVISを使うことができるため、自分の成長に大きくつながったプロジェクトだと考えています。 上記の3つの案件以外にも、STRAVISオンライン セミ ナーの講師を務めるなど幅広く活動しています。 1週間の流れ  仕事内容の紹介で複数案件に参加していると書きましたが、どのような1週間を過ごしているかスケジュールを紹介します。月によって案件の内容は変わりますが、1週間の中でセールス支援がメインの日もあれば導入作業や BPO の作業がメインの日もあります。お客様とのミーティングの予定も多く、常に複数のお客様と関わりがあります。他にも部内の活動として、グループ会や1on1が週に1回程度あります。グループ会では案件状況の報告や、メンバ間でのノウハウ共有を行います。 また、部長やグルプマネージャーと1対1で会話する機会として週に1回15分程度1on1を実施しています。1on1を定期的に実施していることで、業務の中での相談などもより気軽にできていると思います。 終業後は、同期に会ったり、美容院に行ったりとプライベートの時間を確保して気持ちをリフレッシュしています。 GMC1ユニットでの働き方の特長 次に、私が考える「この部署での働き方の特長」についてです。私がGMC1ユニットで働く中で、自分に合っていると感じたポイント2つについて説明します。 私が考える特長の1つめは「幅広さ」です。 私は入社3年目ですが、様々な案件に関わってきました。複数の案件に同時に関わることが多いため、次々と新しいことに挑戦できますし、 スキルアップ できる機会も豊富にあります。そして単に新しいことに挑戦する機会が多いだけでなく、性質の異なる案件を経験することで様々な角度から知識やスキルを習得できる点もメリットに感じています。例えば、導入プロジェクトを経験していたことでセールス支援の時に具体的な導入事例を紹介できた、というように幅広く経験して良かったなと思うことが多いです。このように得た知識を発揮できる場が多いと自分の成長を実感でき、モチベーションの維持にも繋がっています。 また私の場合、この部署での幅広い業務経験が「特にどのスキルを伸ばしていきたいか」「どういう事に特化した人材を目指すのか」という選択肢を拡げることに繋がりました。入社直後はやりたい事のイメージが掴めていませんでしたが、今では選択肢の中から将来のキャリアをイメージできています。 このような理由から、GMC1ユニットの「幅広さ」が私にとってマッチしていたと考えています。 2つ目の特長は、若手のうちからお客様との関わりが多いという点です。 「新規提案中のお客様」「一緒にSTRAVISの導入を行っているお客様」「 BPO で関わるSTRAVISユーザー」など様々な立場のお客様とプロジェクトを通して接することができます。お客様の反応を直接確認できるので、良い反応を貰えると嬉しいですし、それがモチベーションにもつながっています。逆に、お客様の反応から自分の改善するべき点に気づく場合もあります。例えばお客様からの指摘や質問に一人では対応できず、どの部分の理解が不足しているのか気付くこともあります。お客様からの評価と気づきをあらゆる角度から得ることが出来るという点が顧客との関わりが多いメリットだと思っています。 また若手のうちから、セールス支援でも導入プロジェクトでもメインスピーカーとして参加できるので「プレゼン力を身につけたい」「社外でも通用するビジネススキルを身につけたい」といった人にも向いていると思います。学生時代、私は人前で話すのがとても緊張するタイプでしたが、今ではスピーカーとして会議に参加することも徐々に楽しめるようになってきました。 入る前のイメージと入った後感じたこと 私は学生時代、ITとも会計とも無縁の専攻でした。そのため、配属前は自分に会計やシステムの事が理解できるのか、チームのメンバの一員として役に立つことが出来るのか不安を感じていました。また、「若手のうちから挑戦できる」=「放置される」なのではないかという不安もありました。 こんな風に不安を感じながら配属された私ですが、GMC1ユニットでは一人一人のレベルにあった新人教育がされ、先輩からのサポートも手厚いため不安なく働くことができました。具体的に、私が配属後一番初めに行ったのは「STRAVISを知る」事でした。GMC1ユニットではユーザー向けのeラーニングや セミ ナーを多数用意しているので、それらを受講して勉強することができます。さらに導入案件やセールス案件に参加して、実際のプロジェクトの流れや会計の知識、STRAVISの機能や操作について学ぶこともしました。 私のように、入社前・配属前の段階で会計やITの学習経験がない場合でも不安に感じる必要はないです。GMC1ユニットでは自分のスキルに合わせて学んでいくことができます。もし、少しでも興味を持ってくださった方はまずはISIDやSTRAVISを知ることから始めていただけると嬉しいです。 最後に 就活生の皆さんの中には私のようにIT系の専攻ではないけれどISIDに興味を持ってくださっている方もいるかと思います。 GMS 事業部GMC1ユニットでは既にITや会計に詳しいという方はもちろんですが、今はまだ興味を持ち始めた段階だという方も大歓迎です。私の働き方紹介が皆さんの将来を考える材料になればうれしいなと思います。 以下採用ページへのリンクです。興味を持ってくださった方は、ぜひご応募ください。 www.isid.co.jp 執筆: @yasuda.shiori 、レビュー: Ishizawa Kento (@kent) ( Shodo で執筆されました )
アバター