@natsu1211

ゲーム開発、プログラミング言語の話

水面レンダリングの手法まとめ

リアルな水面レンダリングは、CGとゲーム開発の分野において重要なテーマの一つです。これは比較的に複雑なテーマで、更に以下の課題に分けられます。

  • 波のシミュレーション
  • 水のシェーディング
    • 反射
    • 水の透明感の表現
  • 白い泡(Form)の表現

それぞれの課題に対して様々な手法が提案されてきました。 これらの手法を組み合わせして、初めてリアルな水面レンダリングができるようになります。

波のシミュレーション

水面の形を作るためかなり重要。手法の原理によって、以下のように分類できます。

波の重ね合わせ
  • 正弦波(Sinusoids Wave)[Max 1981]
  • ゲルストナー波(Gerstner Wave)[Fournier 1986]
統計モデル
  • 高速フーリエ変換(Fast Fourier Transform)[Mastin 1987]
  • 空間-スペクトル混合法(Spatial -Spectral Approaches)[Thon 2000]
粒子法
  • Wave Particles [Yuksel 2007]
  • Water Wave Packets [Jeschke 2017]
  • Water Surface Wavelets [Jeschke 2018]
物理ベース
プレレンダリング
  • 頂点高さマップ変位(Vertex Height Map Displacement)[Yuri 2005]
  • フローマップ(Flow Map)[Vlachos 2010]
  • オフラインFFTテクスチャベーキング(Offline FFT Texture)[Torres 2012]
  • オフライン流体フレームアニメーションベーキング(bake to flipbook)[Bowles 2017]
その他
  • バンプマッピング(Bump Mapping)[Schachters 1980]
  • フラクタルノイズ(Fractal Noise)[Perlin 1985]
  • 非整数ブラウン運動(Fractal Brownian Motion, FBM)[Addison 1996]
  • Procedural Shape [Ebert 1999]
  • ボクセルベース(Voxel-Based Solutions)[Yann 2003]
  • 二次元波動方程式(2D Wave Equation)[Nishidate 2005]
  • スクリーンスペースメッシュ(Screen Space Mesh)[Muller 2007]
  • Water Wave Simulation [Grenier 2018]

ゲルストナー波

水の透明感の表現

水面に到達する光線は、水面で反射するだけでなく、一部の光線が水面下に入り、吸収と散乱を経て再び水面から放出される現象があります。これを水のサブサーフェススキャッタリング(Sub-Surface Scattering, SSS)現象と呼びます。

物理ベースのレンダリングでは、サブサーフェススキャッタリングを再現する最も標準的な方法は、BSSRDF(Bidirectional Surface Scattering Reflectance Distribution Function、双方向散乱面反射率分布関数)を解くことです。しかし、BSSRDFを解くには膨大な計算量が必要なため、リアルタイムレンダリングが必要なゲームでは、依然として非物理的な経験則レンダリング方法が主に使用されています。

BSSRDF
主流のアプローチは以下の2つあります。

  • 深度色対応表法(Depth Based Look-Up-Table Approach)
  • SSS近似法(Sub-Surface Scattering, SSS Approximation Approach)
Depth Based Look-Up-Table

考え方として、カメラ方向の水のピクセルの深さを計算し、その深さ値に基づいて吸収/散乱LUT(Absorb/Scatter LUT)テクスチャをサンプリングすることで、異なる深さの水の色付けを制御します。

Depth Based LUT法

SSS Approximation

SSSを表現するための近似手法はたくさんありますが、ここで2つ例をあげます。

  • Crest Ocean System [SIGGRAPH 2019]
  • FrostbiteエンジンのFast SSS [GDC 2011]

白い泡の表現

波打ちや船が通過する際、波の一部や船の周辺の水が白くに見えます。これは水に大量の空気が混入し、すべての入射光が反射され、下にあるものに光が透過しなくなるからです。 この白い泡を表現できるかどうかも水面がリアルにみえるかどうかの重要なポイントです。

主な手法は以下にようになります。

  • ヤコビ行列に基づく手法 [Tessendorf 2001]
  • Saturate高さに基づく手法 [GPU Gems 2]
  • シーン深さに基づく方法 [Siggraph 2018][Sea of Thieves]
  • Noise Texture + Flow Map [GDC 2018][Far Cry 5]

水面レンダリングOSS

Crest Ocean System
https://github.com/wave-harmonic/crest

CryEngine
https://github.com/ValtoGameEngines/CryEngine/blob/release/Engine/Shaders/HWScripts/CryFX/Water.cfx

UE4 Dynamic Water
https://github.com/marvelmaster/UE4_Dynamic_Water_Project_V3

Unity BoatAttack
https://github.com/Unity-Technologies/BoatAttack

RoslynでUnityパッケージ内のAPIの呼び出し状況を調べる

やりたいこと

  • とあるUnityパッケージ内で定義されている全公開メソッド(API)を抽出
  • IDEを使わずに、これらのAPIがプロジェクト内での呼び出し場所と回数を抽出、CSVとして出力。

これができると何が嬉しいかというと、 Unityパッケージを作る横断チームにとって、自分が作ったフレームワークが社内のゲームプロジェクトで実際どういう風に使われているのかを静的に、定量的に把握できる。

実装

重要な部分だけ紹介。 まずUnityパッケージの全ソースコードから公開メソッド(API)を抽出、

CompilationContext.cs

    internal class CompilationContext
    {
        public CompilationContext(IEnumerable<string> scriptPaths)
        {
            ScriptContexts = scriptPaths.Select(path => new ScriptContext(path)).ToArray();
        }

        internal ScriptContext[] ScriptContexts { get; }
        internal SyntaxTree[] SyntaxTrees { get; private set; }
        internal CSharpCompilation Compilation { get; private set; }

        internal async UniTask InitializeCompliationAsync()
        {
            SyntaxTrees = await UniTask.WhenAll(ScriptContexts.Select(c => c.CreateSyntaxTreeAsync()));
            var references = ScriptContexts.AsParallel().Where(script => script.Assembly != null)
                .SelectMany(script => script.Assembly.allReferences)
                .Distinct()
                .Select(reference => MetadataReference.CreateFromFile(reference));

            // compile occurs here,
            var compilation = await UniTask.RunOnThreadPool(() => CSharpCompilation.Create("CompliationResult.dll",
                SyntaxTrees, references,
                new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)));

            Compilation = compilation;

            // エラーがあっても、Symbolのサーチ自体はできます
            // var ds = compilation.GetDiagnostics();
            // foreach (var d in ds)
            // {
            //     if (d.Severity == DiagnosticSeverity.Warning)
            //         Debug.LogWarning(d.GetMessage());
            //     else if (d.Severity == DiagnosticSeverity.Error)
            //         Debug.LogError(d.GetMessage());
            // }
        }
    }

ScriptContext.cs

    internal class ScriptContext
    {
        internal ScriptContext(string fullPath)
        {
            FullPath = fullPath;
            ScriptPath = AssetPathHelper.GetPackageAssetPath(fullPath);
            Assembly = AssemblyHelper.GetAssemblyFromScriptPath(ScriptPath);
        }

        // Path in Unity
        internal string ScriptPath { get; }

        // Full path in file system
        internal string FullPath { get; }

        // AST of this script
        internal SyntaxTree SyntaxTree { get; private set; }

        // Assembly include this script
        internal Assembly Assembly { get; private set; }

        internal async UniTask<SyntaxTree> CreateSyntaxTreeAsync()
        {
            var text = await File.ReadAllTextAsync(FullPath).ConfigureAwait(false);
            SyntaxTree = CSharpSyntaxTree.ParseText(text);
            return SyntaxTree;
        }
    }

AssemblyHelper.cs

    internal static class AssemblyHelper
    {
        private const string AssemblyExtension = ".dll";

        private static readonly Dictionary<string, Assembly> s_assemblies;

        static AssemblyHelper()
        {
            s_assemblies = CompilationPipeline.GetAssemblies().ToDictionary(assembly => assembly.name);
        }

        internal static Assembly GetAssemblyFromScriptPath(string scriptPath)
        {
            var assemblyName = CompilationPipeline.GetAssemblyNameFromScriptPath(scriptPath);
            if (assemblyName == null)
                return null;

            if (assemblyName.EndsWith(AssemblyExtension, StringComparison.OrdinalIgnoreCase))
                assemblyName = assemblyName.Substring(0, assemblyName.Length - AssemblyExtension.Length);

            return s_assemblies.TryGetValue(assemblyName, out var assembly) ? assembly : null;
        }
    }

Unity.CompilationPipelineを通して、各ソースファイルが依存しているdllを探し出して、InitializeCompliationAsync()内で各ソースと一緒にコンパイルしています。 あとはMethodDeclarationWalkerを通して、コンパイルで得られたSyntaxTreeに対して全探索を行います。PublicメソッドであればSymbolを保存。

MethodDeclarationWalker.cs

    internal class MethodDeclarationWalker : CSharpSyntaxWalker
    {
        private readonly SemanticModel _semanticModel;
        private readonly List<ISymbol> _symbols = new();

        public MethodDeclarationWalker(SemanticModel semanticModel)
        {
            _semanticModel = semanticModel;
        }

        public override void VisitMethodDeclaration(MethodDeclarationSyntax node)
        {
            var symbol = _semanticModel.GetDeclaredSymbol(node);
            if (symbol != null && symbol.DeclaredAccessibility == Accessibility.Public)
                _symbols.Add(symbol);
        }

        internal List<ISymbol> GetDeclarationSymbols()
        {
            return _symbols;
        }
    }

定義されているPublicメソッドの全Symbolは得られたので、あとはプロジェクトコードのSymbolに対して、フルネームが一致するかどうかを比較すれば、呼び出しされているかどうかがわかります。

SymbolFinder.cs

        internal static async UniTask<HashSet<string>> FindMethodDeclarationSymbolNamesAsync(
            IEnumerable<string> scriptPaths)
        {
            var compilationContext = new CompilationContext(scriptPaths);
            await compilationContext.InitializeCompliationAsync();

            var syntaxTrees = compilationContext.ScriptContexts.Select(s => s.SyntaxTree);
            var compilation = compilationContext.Compilation;

            var symbolNameBag = new ConcurrentBag<string>();
            await UniTask.RunOnThreadPool(() => Parallel.ForEach(syntaxTrees, tree =>
            {
                var semanticModel = compilation.GetSemanticModel(tree);
                var walker = new MethodDeclarationWalker(semanticModel);
                walker.Visit(tree.GetRoot());
                foreach (var s in walker.GetDeclarationSymbols()) symbolNameBag.Add(s.ToDisplayString());
            }));
            return symbolNameBag.ToHashSet();
        }

MethodInvocationWalker.cs

    internal class MethodInvocationWalker : CSharpSyntaxWalker
    {
        private readonly SemanticModel _semanticModel;
        private readonly HashSet<string> _calleeSymbolNames;
        private readonly List<(ISymbol callee, ISymbol caller)> _symbolsFound = new();

        public MethodInvocationWalker(SemanticModel semanticModel, HashSet<string> calleeSymbolNames)
        {
            _semanticModel = semanticModel;
            _calleeSymbolNames = calleeSymbolNames;
        }

        public override void VisitInvocationExpression(InvocationExpressionSyntax node)
        {
            var calleeSymbol = _semanticModel.GetSymbolInfo(node).Symbol;
            if (calleeSymbol == null) return;
            if (_calleeSymbolNames.Contains(calleeSymbol.ToDisplayString()))
            {
                var callerSymbol = SymbolFinder.FindMethodCallerSymbol(_semanticModel, node);
                if (callerSymbol != null)
                {
                    _symbolsFound.Add((calleeSymbol, callerSymbol));
                    Debug.Log($"Callee: {calleeSymbol.ToDisplayString()}");
                    Debug.Log($"Caller: {callerSymbol.ToDisplayString()}");
                }

            }
        }

        internal List<(ISymbol callee, ISymbol caller)> GetMethodSymbols()
        {
            return _symbolsFound;
        }
    }

ここまでできれば、あとは好きなように処理して出力すればいいです。

感想

  • UnityのビルドシステムはMSBuildではないので、通常の.NETプロジェクトみたいにRoslynのWorkspace APIを簡単に使えない。
  • ソースコードの量に比例するため、コンパイルとSymbolの比較にかなり時間がかかる。並列化が必須。
  • コンパイル済みのdllファイルを調べば、ソースコードを再度コンパイルしなくてもやりたいことができそうな気がしますので、また時間ある時に試してみたいです。

.NETコンパイラープラットフォーム「Roslyn」をわかりやすく解説

Roslyn登場の背景

従来のC#/VBコンパイラーはブラックボックスであり、ソースコードを与えるとコンパイル結果のアセンブリが出力されるだけであった。Visual Studioなどが提供するIDE機能の中には、入力補完、リファクタリング、参照の検索といった「コンパイラーそのもの、あるいはそれに類する機能」が必要とされるものがある。IDEの側で、これらの機能を追加したりコンパイラーのアップデートに追随したりするケースを考えると、ブラックボックスコンパイラーだけでは実現が難しく、正式なコンパイラーと同様の処理を推測し実装する必要があった。またこのような機能を持つツールはVisual Studioだけではなく、マイクロソフト以外のサードパーティの企業や、開発者個人が実装することもあり、機能をオープンにする要望が高まっていた。

そこでマイクロソフトは、一連のコンパイラー機能をコンパイラープラットフォームとして設計し直し、APIとして提供することを決めた。その結果、Compiler platform as a Serviceとして、リリースするに至った。Roslyn(ロズリン)はコードネームで、正式名称は「.NET Compiler Platform」になります。

Roslynの概要

Roslynのソースコードhttps://github.com/dotnet/roslyn から確認できます。
Roslynは、4つの APIレイヤー (Compiler API、Diagnostics API、Scripting API、Workspace API) で構成されています。そして、これらのAPIは複数のバイナリに分けられており、Microsoft.CodeAnalysisMicrosoft.CodeAnalysis.CSharpなどのNugetパッケージを通して配布しています。

Compiler API

コンパイルの一連の過程(パイプライン)の各プロセスを、APIとしてアクセスできるようにしたもの。
また、アセンブリ参照、コンパイラオプション、ソースコードファイルを含む、一回のコンパイルに必要な全情報のスナップショットも含まれています。

Diagnostics API

コンパイルエラーや警告をユーザーが追加できるように、APIとして提供するようにしたもの。

Scripting API

C#のコード片を評価し結果を返すAPIです。 C# 対話型 REPL (Read-Evaluate-Print Loop) ではこれらの API を使用します。 REPLを使用すると、C#スクリプト言語として使用し、記述時に対話形式でコードを実行できます。

Workspace API

Workspace API は、ソリューション、プロジェクト、ソースコードといった「コンパイル/コード解析などを行う単位」を扱うためのAPI。これらの単位は、Visual Studioで扱ってきたソリューションやプロジェクトなどと同じものであるが、APIとしてはVisual Studioに依存していない。ファイルの解析、オプションの構成、プロジェクト間の依存関係の管理の手間を省いてくれます。

Compiler API

前述の通り、Compiler APIコンパイル過程の全情報へのアクセスを可能にするAPIで、Roslynの4つのAPIレイヤーの中で最も重要です。

Roslynのコンパイルプロセスの4つのフェーズに分かれており、Compiler APIもこれらのフェーズに対応しています。

  • Parser: Syntax Trees APIが対応。ソースコードを入力として、字句解析および構文木の解釈を行う。IDEでは、フォーマットや色付け、アウトライン表示などがこのAPIを利用している。
  • Symbols、Metadata Import: Symbols API が対応。Parserが生成した構文木に加えて、参照している外部アセンブリからインポートしたメタデータから、シンボルを生成する。IDEでは、[Navigate to(移動)]や[オブジェクト ブラウザー]などが利用している。
  • Binder: Binding and Flow Analysis APIが対応。生成されたシンボルに識別子をバインディングする。[名前の変更]や[メソッドの抽出]が利用している。
  • IL Emitter: Emit APIが対応。ILとしてアセンブリを出力する。デバッグ時のEdit and Continue(エディットコンティニュー)機能が利用している。

まとめ

Roslynの登場により、ブラックボックスだった.NETのコンパイルプロセスがオープンになりました。
IDEにもたらした恩恵は言うまでもないですが、一般のユーザーにとっても、Analyzerの自作や構文解析の結果を利用したコードの自動生成が格段と簡単になりました。最終的にユーザーのコード品質とパフォーマンスの向上に繋がりました。
マイクロソフトに感謝。ではまた。

ゲームのスクリプト言語、Lua以外の選択肢

ゲーム開発におけるスクリプト言語

ゲーム開発において、UIやゲームプレイなどを実装する際、スクリプト言語を使うことがよくあります。太古の事例でいうと、World of Warcraftプラグイン開発ではLuaを使います。最近の日本の開発事例でいうと、ヘブンバーンズレッドとFFVII EVER CRISISはUnityで開発されていますが、ADVパートの開発はC#ではなく、Luaを採用しています。

techcon.gree.jp

speakerdeck.com

Unityの場合、C#しかオフィシャルにサポートされてないため、Luaをサポートする場合は独自に実装する必要あります。UnrealEngineの場合、C++以外にBlueprintもオフィシャルにサポートされています。いずれにせよ、原理的にゲームエンジンがサポートしていないスクリプト言語を導入するには、スクリプト言語インタプリタの組み込み、エンジン機能をアクセスするためのバインディングコードの生成が必要です。

スクリプト言語に厳密な定義はありませんが、通常はコンパイル不要のインタプリタ型言語がスクリプト言語とみなされます。これらの言語は通常、型がないか動的型付けであり、記述もその分簡単になります。

以上を踏まえて、エンジン言語(通常はC/C++/C#)を使用せずにスクリプト言語でゲームロジックを書く利点は明確です、

  • スクリプト言語で書かれていた部分であれば、ゲームのバイナリを変更せずに(つまりバージョンアップとアプリストア再審査を必要としない)、修正後のコードをテキストアセットとして配布すれば、不具合のhotfixを行うことができます。
  • コンパイルせずに変更を確認できるため(ホットリロード)、イテレーションを速めることができます。
  • プログラマー(プランナー、演出など)が開発に参加するための敷居を下げられます。

一方で、ゲーム開発にスクリプト言語を導入するデメリットもあります、

  • プロジェクトの複雑さが増し、エンジン言語とスクリプト言語両方のコードベースを管理する必要があります。
  • インタプリタ型言語のパフォーマンスは通常、コンパイル型言語に劣り、パフォーマンスにシビアな場面に適していません。ゲームエンジン言語とスクリプト言語のランタイム間のやり取りが必要になるため、オーバヘッドが発生します(UnityにLuaを導入する場合、元々C++エンジンランタイムとC#ランタイムの間に一定のオーバヘッドがある上に、C#Luaのランタイム間のオーバヘッドがさらに発生します)。
  • コンパイルしない分、実行時に初めて分かるバグが多くなり、コード品質の保証とデバッグが難しくなります。

総合的に考えて、これらのデメリットを差し引いても、hotfixができるのとイテレーション高速化のメリットがやはり大きい。規模の大きな開発ほどスクリプト言語を導入する価値があると言えるでしょう。

Luaの欠点

ゲーム開発用のスクリプト言語を選択する際、高速・小さなランタイムサイズ・ゲームエンジンのランタイムと簡単にやり取りできる言語が望ましい。Luaはまさにこのような言語で、ランタイムサイズは数百KB程度で、実行速度が早く、Cに簡単に組み込めるようにデザインされてます。大規模ゲームプロジェクトでの採用実績もあり、多くのプロジェクトでスクリプト言語を導入する際の第一選択となっています。また、xLuaのような実績あるOSSがあり、UnityやUnrealのゲームにLuaの導入が容易。

github.com

しかし、2024年の視点から見ると、Luaにはいくつか大きな欠点があります。もっと良い選択肢がある今、大規模プロジェクトの開発にLuaの採用はあまりおすすめしません。

  • 言語機能が貧弱、文法もモダン言語と大きな違いがあり、実は意外と勉強コストが高い
    • スコープがデフォルトでglobalであり、あらゆるところでlocalを書かないといけないのがだるい。
    • インデックスはデフォルトで1から始まる。
    • あらゆるところにあるnil
    • OOPのサポートが不十分。
    • unicodeのサポートがありません。文字列はただのbytesです。
    • switch/caseもパターンマッチングもありません。長いif/elseを書くしかありません。
    • 型注釈のサポートがなく、IDEでの静的チェックも欠けています。
  • 開発が活発ではありません
    • Lua5.3からLua5.4になるまで5年かかりました、言語機能の改善があまり望めません。
  • ベストプラクティスの欠如
    • 今でもtableの内容の標準的な出力方法はありません。
    • モジュールを定義する方法が少なくとも5つあり、クラスを定義する方法が3つあります。
    • 標準ライブラリが貧弱、各プロジェクトが独自に車輪の再発明しているため、コードをプロジェクト間で再利用することが難しい。

ADVパートで、手続き的な記述しかしないみたいな限定的な使いかたでしたらLuaで全然いいですが、モジュールをつくり、もう少しOOP的なことをやろうとすると辛くなります。

TypescriptとPuerTSのススメ

そこでおすすめしたいのはES6以降のJavaScript/Typescriptです。 Luaと比べてTypescriptを使うことはいくつか明確なメリットがあります。

  • Typescriptの静的型チェックの恩恵。
  • IDEのサポートを受けられる。
  • asyncのサポート。TypescriptからC#のasync関数をawaitできます。
  • JavaScriptの豊富なエコシステムを利用できます。
  • WebGLでのパーフォーマンス向上。

実際、xLuaの開発者もLuaの問題を認識しており、PuerTSを別途開発しました。Javascriptのランタイムは高性能なV8とコンパクトなQuickJSから選べます。

github.com

具体的な使い方はここで詳しく紹介しないですが、実際Typescriptでゲームロジックを書くのはどんな感じなのか、TypescriptからUnity UIにハンドラを登録する一例を紹介。

Unity Editor

ts側 (UIEvent.mts)

function init(monoBehaviour: CS.UnityEngine.MonoBehaviour): void {
  let button = monoBehaviour.GetComponent(puer.$typeof(CS.UnityEngine.UI.Button)) as CS.UnityEngine.UI.Button;
  let input = monoBehaviour.transform.parent.GetComponentInChildren(puer.$typeof(CS.UnityEngine.UI.InputField)) as CS.UnityEngine.UI.InputField;
  let toggle = monoBehaviour.transform.parent.GetComponentInChildren(puer.$typeof(CS.UnityEngine.UI.Toggle)) as CS.UnityEngine.UI.Toggle;
  button.onClick.AddListener(() =>{
    console.log("button pressed..., input is: " + input.text);
  });
  toggle.onValueChanged.AddListener((b) => {
    console.log("toggle.value=" + b);
  });
}
export {init};

C#側 (UIEvent.cs)

using System;
using UnityEngine;
using Puerts;

public class UIEvent : MonoBehaviour
{
    // JavaScript runtime adaptor
    static JsEnv jsEnv;

    void Start()
    {
        if (jsEnv == null)
        {
            jsEnv = new JsEnv();
            jsEnv.UsingAction<bool>();//toggle.onValueChanged
        }
        // ts側で定義したinitメソッドをActionにラップして、C#側で使えるようにする
        var init = jsEnv.ExecuteModule<Action<MonoBehaviour>>("UIEvent.mjs", "init");

        if (init != null) 
            init(this);
    }
}

UIEvent.csはButtonにアタッチしているスクリプトで、JsEnvオブジェクトを通じてTypescript(正確にはコンパイル後のJavascript)で定義しているinitメソッドを呼び出してことがわかります。

パフォーマンス比較

puerts.github.io

PureTSの公式サイトにPureTSとxLuaのベンチマークも乗っています。
長いので、まとめるとJITが使える環境であればPuerTSはxLuaに完勝、IOSみたいなJITが使えない環境であれば、PuerTSとxLuaの実行速度は同等です。

まとめ

言語機能、実行速度、エコシステム、IDEサポート、どれを取ってもTypeScriptが勝っている現在、 プロジェクトにスクリプト言語を導入するなら、敢えてLuaを採用する理由はないと思います。

uGUIの内部実装を完全理解する(1) - 全体構成

なぜ今さらuGUI

uGUIはUnity 4.6で導入されて以来、UnityでランタイムUIを作成するための標準ツールとなりました。現在、Unityは新しいUI作成ツールUI Toolkitをリリースしていて、UI Toolkitの習得もますます重要になってきています。しかし、UI Toolkitの公式の位置付けは依然としてエディターUIの作成が主であり、しばらくの間、uGUIはゲーム内UI制作の主要なツールとして、UI Toolkitと共存することが予想されます。

まだまだ絶賛開発中のUI Toolkitとは違い、uGUIはすでに成熟した技術で、コードベースが大きな変更されることはもうないでしょう。ソースコードリーディングするにはむしろ好都合です。UI Toolkitの思想はWebフロントやWPFに近いですが、uGUIはより従来のUIコントロールベースの考えに近いUIフレームワークです。ここでuGUIとUI Toolkitを比較したいわけではないですが、数年間重度にWPF使っていた私からして、(エディターUIと比べて)比較的にシンプルなゲーム内UIを作る場合、ちゃんとUXML/USSを作りデータバインディングをやるより、uGUIで充分というかむしろ柔軟だと感じています(逆に複雑になりがちなエディターUIを作る場合、IMGUIより断然UI Toolkitがおすすめ)。2つかなり思想が異なるUIフレームワークですが、入力とEventに対する応答処理など、共通する部分もあります。実際のゲーム開発でより複雑なUIの作成やパフォーマンスの最適化に役立つことはもちろん、UI Toolkitの勉強の手助けにもなると考えています。

本当は数年ぶりに仕事でがっつりuGUI触るから、uGUIを振り返ってみようなんて言えません・・・

uGUIの構成

uGUIはピュアC#で書かれており、Unity UIというパッケージを通じて配布されています。このシリーズでは、Unity UI 1.0.0バージョンのソースコードを徹底的に分析します(changelogによると、1.0.0から2.0.0への変更はTextMeshProが同じパッケージに統合されただけで、他の部分に変更はありません)。

Unity UIパッケージ(Runtime部分)の中身は以下のようになる、

UIフレームワークとして、少なくとも以下の問題を解決する必要があります。

  • UIコントロールをどう提供し、レンダリングするか
  • ユーザーの入力にどう応答するか
  • 複数のUI要素をどうレイアウトするか

uGUIも例外ではなく、上記の問題に対する解決策を提供しています。 そのため、このシリーズではuGUIのソースコードを以下の3つの部分に分けて解説します。

ビジュアルコンポーネントレンダリング

Unityではシーン内のすべてのオブジェクトはGameObjectであり、異なるコンポネントをつけることで異なる機能を実現します。ここで言うビジュアルコンポーネントとは、ImageやTextなどのScriptのことで、これらのScriptコンポネントをGameObjectにつけることで、ビジュアル内容を表示する機能を実現しています。Image、Textなどのコンポネントは、抽象クラスGraphicを継承しています。Graphicクラスには、meshやmaterial情報など、UIをレンダリングするために必要なさまざまな情報が含まれています。ここで一つ重要なのは、UIは2次元に見えますが、Unityでは全部3次元のmeshによって構成されています。

実際のレンダリングは、主にCanvas、CanvasRenderがUnityエンジンの描画部分と協力して行われます(エンジンのランタイムがさらに低レベルなグラフィックAPIを使い、GPUレンダリングしてもらいます)。CanvasとCanvasRenderはC++で実装されたコンポーネントで、uGUIに属しているわけではなく、Unityエンジンのランタイムの一部です(そのため、namespaceはUnityEngine.UIではなくUnityEngineです)。uGUIでは、CanvasのユティリティクラスであるCanvasUpdateRegistryを提供しており、Canvas内の各Graphicクラスのプロパティが変更された際に、正しくRebuild(レイアウトの再計算とUIの再描画)が行われるようにしています。

入力の応答

ImageやTextなどのビジュアルコンポーネントの他に、ButtonやToggleなど、ユーザーの入力に応答できるコンポーネントもあります。ユーザーの入力は、Unityエンジンのランタイムによって一連のEventに変換され、これらのEventはEventSystemとInputModuleを通じてDispatchされます。実際イベントを処理する対象は、ユーザーがクリック/タッチした点からRaycastの処理によって見つかり、登録されたイベントハンドラが呼び出されます。

UI要素のレイアウト

uGUIでは、基本的なレイアウト機能を実現するためにRectTransformコンポネントを提供しています。UIのmeshをどの位置にどれぐらいの大きさで描画すべきかRectTransformによって決まるため、GraphicクラスもRectTransformに依存しています。しかし、RectTransformもCanvasと同様にC++で実装されたコンポーネントであり、uGUIの一部ではなく、オープンソースでもありません。より複雑なレイアウトをするために、uGUIはLayoutGroupなどのコンポーネントを通じて自動レイアウト機能を提供しています。

また、uGUIは各種コンポーネントに専用のEditorインターフェースも提供しており、エディター内でコンポーネントの各種プロパティを編集できるようにしています。Runtime部分のソース読みが完了した後、Editorの実装についてもある程度解読していく予定です。

以下はuGUIの全体クラス図です。すべてのクラスとインターフェースが含まれているわけではありませんが、ソースコードの構成と照らし合せれば、各クラスの役割のイメージが掴めやすくなると思います。

今後のシリーズ記事では、ここに示された各クラスの実装について分類して説明していきます。