【C#】Silk.NET+WebGPUで三角形を描画する

以下の記事に基づいた内容のプロジェクトをGitHubにアップしました。
動作確認等にご利用ください。 github.com

はじめに

WebGPUについて

WebGPUはWebGL Nextとして策定が進められている、3Dグラフィクスおよび計算処理の機能を提供するAPIです。
WebGPUはDirect3DやMetal、VulkanのようなネイティブなグラフィクスAPIの上に抽象化レイヤーとして構築されています。

developer.mozilla.org

WebGPUはその名の通りWeb用に設計されていて、実際にはJavaScriptで記述するWeb APIとして標準化が行われています。
一方でWeb APIとして標準化されるということはさまざまな環境で実行可能である必要があり、必然的にその仕様はクロスプラットフォームになります。このクロスプラットフォームという特徴はWebアプリだけではなくネイティブアプリケーションを作る際にも有用です。
グラフィクスAPIは、WindowsならDirect3DかVulkan、macOSならMetal、LinuxならVulkanなどのようにプラットフォームによって分かれています(実際にはGPUベンダーによって対応状況が分かれている)。 そのため直接ネイティブAPIを使用する場合プラットフォームによって実装方法が変わってしまいますが、WebGPUはプラットフォームごとのAPIの違いを吸収して抽象化された実装を提供してくれるため、理論的には単一のコードベースで対応するどのプラットフォームでも動くアプリケーションを作ることができます。

余談:Vulkanについて

ご存知の方も多いと思いますが、本来ならば設計思想上VulkanがクロスプラットフォームAPIになってもおかしくなかったはずでした。 しかし主要なGPUベンダー(NVIDIAIntelAMDなど)が皆Vulkanのネイティブサポートを提供した中、Appleが自社デバイスにおけるVulkan APIのネイティブサポートを行いませんでした。このような歴史的経緯によって新世代のグラフィクスAPIにおいては、以前のOpenGLのようなクロスプラットフォームなネイティブグラフィクスAPIは存在しなくなりました。
なお、現在ではMolten VKというVulkanをMetal API上で動作させるための環境が提供されていますが、ネイティブなVulkanと比較すると多少の機能制限があります

github.com

追記
WebGPU策定の功労者の一人であるkvark氏のブログにて、Vulkanが当初目指していた高い移植性が達成できなかった経緯や、WebGPUがどのような流れでネイティブAPIとして注目されるようになったのかが触れられていたので紹介させていただきます。

kvark.github.io

WebGPUのネイティブ実装について

前項で述べた通り、WebGPUは基本的にJavaScriptで記述するWeb APIですが、そのネイティブ実装も開発が進められています。 その仕様はC APIとしてwebgpu.hというヘッダーファイルによって外部に公開されています。
このwebgpu.hに基づいたネイティブ実装としては、C++実装のDawnと、Rust実装のwgpuの2つが有名だと思います(wgpuの方はあくまでRustで書かれた実装であるため、ヘッダーファイルへの対応はwgpu-native(wgpuのCバインディング)を通して行われています)。

github.com

github.com

DawnはChromiumに組み込むことを主目的としたGoogle主導の実装、wgpuはFirefox(そのレイアウトエンジンであるServo)に組み込むことを想定したMozillaの寄与がある実装になっています。 ブラウザ以外での利用例も勿論あり、例えばRustで実装されたBevyEngineというゲームエンジンはwgpuライブラリ上に構築されています。

bevyengine.org

今回はC#でWebGPUを触ってみるということなのですが、もし何も用意がないならCの関数を呼び出すためにP/Invokeを使う必要がありやや面倒です。 幸いなことに、C#にはこのような時に使えるSilk.NETというライブラリがあります。

Silk.NETについて

github.com

このライブラリはOpenGLOpenCLDirectXやVulkanのようなネイティブコードで構築されたライブラリに対するC#バインディングを提供してくれるのですが、WebGPUのバインディングも提供されています。
webgpu.hの実装としては先ほど紹介した2つのライブラリのうち、wgpuによるものがデフォルトで利用できるようになっています。 .NET Foundation傘下で現在も積極的に更新されており、将来性もあります(あるはずです)。
今回はこのライブラリを使って、C#でWebGPUを使って三角形の描画を行ってみたいと思います。

検証環境

記事中のコードは、以下の2つの環境で検証を行いました。

導入するNuGetパッケージ

Silk.NETの機能はNuGetパッケージの形で提供されています。 今回必要なのは以下の4つのパッケージです。

パッケージ名 機能 使用したバージョン
Silk.NET.Windowing ウィンドウの作成 2.22.0
Silk.NET.WebGPU WebGPU APIC#バインディングの提供 2.22.0
Silk.NET.WebGPU.Native.WGPU wgpu-nativeに基づいた各プラットフォームごとのWebGPUネイティブライブラリの提供 2.22.0
Silk.NET.WebGPU.Extensions.WGPU 拡張機能の提供(記事ではInstanceBackendを使用) 2.22.0

このパッケージはすべて最初に導入してしまっても特に問題ありませんが、記事中適宜必要になったところで導入の記述があります。

1. ウィンドウの作成

まず描画を行うためのウィンドウを作成してみます。 この段階で必要なのはSilk.NET.Windowingパッケージです。 使用しているIDEのUI上で操作するか、プロジェクトディレクトリでdotnet add package Silk.NET.Windowingを実行することでインストールしてください。
以下にエントリポイントとなるProgram.csの実装を示します。

using Silk.NET.Windowing;

// 既定値と変更したい値を指定してWindowOptionsを作成
var windowOptions = WindowOptions.Default with
{
    // タイトル
    Title = "Silk.NET WebGPU",
    // グラフィクスAPIを指定しない
    API = GraphicsAPI.None
};

// オプションを指定してウィンドウを作成
var window = Window.Create(windowOptions);
// window.Initialize()はRun()内で実行されるため不要
// ウィンドウを開く
window.Run();

このようにSilk.NETでは非常に簡単な手続きでウィンドウを作成することができます。

※WindowOptionsの値一覧

今回はTitleとAPIのみ指定しましたが、画面サイズやFPSなどもWindowOptionsによって指定することができます。
参考のため、インスタンス作成時に設定が要求されるnull非許容なプロパティの意味と、WindowOptions.Defaultによって指定される既定値を記しておきます。

var windowOptions = new WindowOptions
{
    // 可視状態
    IsVisible = true,
    // ウィンドウの位置
    Position = new Vector2D<int>(50, 50),
    // ウィンドウのサイズ
    Size = new Vector2D<int>(1280, 720),
    // 1秒間に描画するフレーム数
    FramesPerSecond = 0d,
    // 1秒間に更新する回数
    UpdatesPerSecond = 0d,
    // 既定で使用するグラフィクスAPI
    // Default      : OpenGL 3.3
    // DefaultVulkan: Vulkan 1.1
    API = GraphicsAPI.Default,
    // ウィンドウのタイトル
    Title = Assembly.GetEntryAssembly()?.GetName().Name ?? "Silk.NET Window",
    // ウィンドウの状態
    // Normal       : 通常
    // Minimized    : 最小、タスクバーにアイコンが表示される
    // Maximized    : 最大、タスクバー以外のデスクトップ全体を覆う
    // Fullscreen   : フルスクリーン
    WindowState = WindowState.Normal,
    // ウィンドウの境界線
    // Resizable    : ボーダーは表示され、サイズ変更可能
    // Fixed        : ボーダーは表示されるがサイズ変更不可
    // Hidden       : ボーダーは非表示
    WindowBorder = WindowBorder.Resizable,
    // 垂直同期を有効にするか
    VSync = true,
    // SwapBuffers()の呼び出しを描画時に自動で行うか
    ShouldSwapAutomatically = true,
    // ビデオモード
    // Default      : コメントでは「This uses the window size for resolution and doesn't care about other values.」
    //                とのことだが、実際にはResolution、AspectRatioEstimate、RefreshRateのすべてがnullとして設定される
    VideoMode = VideoMode.Default,
};

2. WebGPUの初期化

ここではWebGPUのAPIを用いて描画するための準備を行います。

2-1. unsafeの有効化、パッケージの導入

WebGPUにおいてはポインタを使用するため、プロジェクトにおいてunsafeコードの使用を許可する必要があります。
csprojのPropertyGroup内に<AllowUnsafeBlocks>true</AllowUnsafeBlocks>を追加します。
また、ここから前項のSilk.NET.Windowingパッケージに加えて新たに3つのパッケージが必要になります。

  • Silk.NET.WebGPU
  • Silk.NET.WebGPU.Native.WGPU
  • Silk.NET.WebGPU.Extensions.WGPU

前項のパッケージと同様に導入を行なってください。
多少の違いはあるかもしれませんが、ここまででcsprojは概ね以下のようになっているはずです。

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net9.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <RootNamespace>SilkWgpu</RootNamespace>
        <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    </PropertyGroup>

    <ItemGroup>
      <PackageReference Include="Silk.NET.WebGPU" Version="2.22.0" />
      <PackageReference Include="Silk.NET.WebGPU.Extensions.WGPU" Version="2.22.0" />
      <PackageReference Include="Silk.NET.WebGPU.Native.WGPU" Version="2.22.0" />
      <PackageReference Include="Silk.NET.Windowing" Version="2.22.0" />
    </ItemGroup>

</Project>

2-2. 作成するクラス・構造体の説明

WebGPUの初期化時に作成する必要があるのは主に以下の5つのクラス・構造体です。

型名 機能
WebGPU webgpu.hで提供されている関数群へのアクセスを提供
Instance GPUオブジェクト(JavaScript APIにおけるnavigator.gpuに相当するもの)を表す
Surface 描画対象を表す
Adapter GPUアダプタ、Device作成後は不要
Device 論理GPUバイスを表す

機能の概略も示しましたが必ずしもこの段階で理解する必要があるものではなく、実装しているうちに感覚的に使い方が分かれば良いと思います。
個人的には特にAdapterとDeviceの違いが分かりづらかったのですが、以下の記事ではこの違いを丁寧に説明してくださっています。

eliemichel.github.io

上記の記事はC++におけるWebGPUの実装ガイドの一部ですが、C#でやることも大きくは変わらないため他のセクションもかなり参考になると思います。

2-3. 初期化用ユーティリティクラスの作成

WebGPUクラス以外の構造体の作成などを行うWebGpuInitializeUtilクラスを実装します。

using System.Runtime.InteropServices;
using Silk.NET.Core.Contexts;
using Silk.NET.WebGPU;
using Silk.NET.WebGPU.Extensions.WGPU;
using Silk.NET.Windowing;

namespace SilkWgpu;

public static class WebGpuInitializeUtil
{
    public static unsafe Instance* CreateInstance(IWindow window, WebGPU wgpu)
    {
        // ウィンドウを供給しているプラットフォームを取得
        // 例1: macOS    : Glfw, Cocoa
        // 例2: Windows  : Glfw, Win32
        var kind = window.Native!.Kind;
        InstanceBackend backend;
        if (kind.HasFlag(NativeWindowFlags.Win32))
        {
            // Windowsの場合はDX12を使用
            backend = InstanceBackend.DX12;
        }
        else if (kind.HasFlag(NativeWindowFlags.Cocoa))
        {
            // macOSの場合はMetalを使用
            backend = InstanceBackend.Metal;
        }
        else
        {
            // その他の場合はVulkanを使用
            backend = InstanceBackend.Vulkan;
        }

        // Silk.NET.WebGPU.Extensions.WGPUが提供するInstanceExtrasを使用してバックエンドを指定できる
        InstanceExtras extras = new()
        {
            Backends = backend
        };

        // InstanceDescriptor.NextInChainにInstanceExtras.Chainを設定
        InstanceDescriptor descriptor = new()
        {
            NextInChain = &extras.Chain
        };

        // Instance(JavaScript APIにおけるnavigator.gpuに相当するもの)を作成
        // https://developer.mozilla.org/ja/docs/Web/API/Navigator/gpu
        return wgpu.CreateInstance(descriptor);
    }

    public static unsafe Surface* CreateSurface(IWindow window, WebGPU wgpu, Instance* instance)
    {
        // 与えられたIWindowに紐づけられたSurfaceを作成
        return window.CreateWebGPUSurface(wgpu, instance);
    }

    public static unsafe Adapter* CreateAdapter(WebGPU wgpu, Instance* instance, Surface* surface)
    {
        Adapter* adapter = null;
        RequestAdapterOptions options = new()
        {
            CompatibleSurface = surface,
            // RequestAdapterOptions.backendTypeは既にサポートされていない
            // 代わりにInstance作成時のInstanceExtras.Backendを使用する
            // BackendType = BackendType.Null,
            PowerPreference = PowerPreference.HighPerformance
        };

        var callback = PfnRequestAdapterCallback.From(
            (status, wgpuAdapter, msgPtr, _) =>
            {
                if (status == RequestAdapterStatus.Success)
                {
                    // adapterに値を設定
                    adapter = wgpuAdapter;
                }
                else
                {
                    var msg = Marshal.PtrToStringAnsi((IntPtr)msgPtr) ?? string.Empty;
                    throw new InvalidOperationException($"Adapter作成に失敗: {msg}");
                }
            });

        // Adapterを作成
        // 参考:https://eliemichel.github.io/LearnWebGPU/getting-started/adapter-and-device/the-adapter.html
        wgpu.InstanceRequestAdapter(instance, options, callback, null);
        // PfnRequestAdapterCallbackは実際には同期的に呼ばれるため、成功していれば値が設定されている
        return adapter;
    }

    public static unsafe Device* CreateDevice(WebGPU wgpu, Adapter* adapter)
    {
        Device* device = null;
        var callback = PfnRequestDeviceCallback.From(
            (status, wgpuDevice, msgPtr, _) =>
            {
                if (status == RequestDeviceStatus.Success)
                {
                    // deviceに値を設定
                    device = wgpuDevice;
                }
                else
                {
                    var msg = Marshal.PtrToStringAnsi((IntPtr)msgPtr) ?? string.Empty;
                    throw new InvalidOperationException($"Device作成に失敗: {msg}");
                }
            });

        DeviceDescriptor descriptor = default;
        // Deviceを作成
        // 参考:https://eliemichel.github.io/LearnWebGPU/getting-started/adapter-and-device/the-device.html
        wgpu.AdapterRequestDevice(adapter, descriptor, callback, null);
        // PfnRequestDeviceCallbackは実際には同期的に呼ばれるため、成功していれば値が設定されている
        return device;
    }

    public static unsafe void ConfigureSurface(IWindow window, WebGPU wgpu, Surface* surface, Device* device)
    {
        // 内部的なswap chainの構成を行う
        SurfaceConfiguration configuration = new()
        {
            Device = device,
            // 幅と高さをウィンドウのサイズに合わせる
            // ウィンドウサイズが変更された場合は再設定する
            Width = (uint)window.Size.X,
            Height = (uint)window.Size.Y,
            Format = TextureFormat.Bgra8Unorm,
            // 先入れ先出し
            PresentMode = PresentMode.Fifo
        };

        wgpu.SurfaceConfigure(surface, configuration);
    }

    public static unsafe void SetErrorCallback(WebGPU wgpu, Device* device)
    {
        var callback = PfnErrorCallback.From((type, msgPtr, _) =>
        {
            var msg = Marshal.PtrToStringAnsi((IntPtr)msgPtr) ?? string.Empty;
            Console.WriteLine($"Unhandled error: {type}: {msg}");
        });

        // エラーコールバックを設定
        wgpu.DeviceSetUncapturedErrorCallback(device, callback, null);
    }
}

構造体の作成用のメソッドが4つの他、swap chainの構成を行うConfigureSurfaceメソッドとエラー発生時にConsoleへ出力するための設定を行うSetErrorCallbackメソッドの計6つのメソッドを実装しました。
コード中にもいくつかコメントとして記載しましたが、WebGPU APIにおいては少し独特な記法が存在します。

Descriptor(記述子)

インスタンス(ここでインスタンスSilk.NET.WebGPU.Instance型のことではなく、一般名詞としてのクラスから生成された実体のことを指しています)を作成する前に、InstanceDescriptorDeviceDescriptorのような接尾辞がDescriptorの構造体(記述子)をあらかじめ作成しておいて、それを作成時の引数に指定するパターンです。インスタンス作成時に多くの引数が必要な際に使われることがあるパターンで、パラメータを記述子のフィールドとしてまとめることでインスタンス作成時の記述の見通しをよくしたり、パラメータの可搬性を高めたり(インスタンスを作るのとは別の場所で記述子を予め作っておくなど)することができます。

参考:

eliemichel.github.io

Struct-Chaining

記述子やそれに関連する構造体の最初のフィールド(NextInChain)はChainedStructという構造体へのポインタになっています。これによって構造体を連鎖的に繋ぎ合わせることができ、インスタンス作成時の将来的な拡張性を担保しています。

参考:

webgpu-native.github.io

PfnRequestOOOCallback

WebGPU.InstanceRequestAdapterWebGPU.AdapterRequestDeviceインスタンスそのものを返さず、設定したコールバック中で成功時にインスタンスを返します。
ただこのコールバックは今回のユースケースでは必ず同期的に実行されるため、メソッド終了時に値を返すようにしています。

2-4. 初期化

実際に各リソースの作成・破棄を行うWebGpuHandlerクラスを実装します。リソースの作成には先ほどのWebGpuInitializeUtilを利用します。

using Silk.NET.WebGPU;
using Silk.NET.Windowing;

namespace SilkWgpu;

public sealed unsafe class WebGpuHandler(IWindow window) : IDisposable
{
    private WebGPU wgpu = null!;
    private Instance* instance;
    private Surface* surface;
    private Device* device;

    public void Initialize()
    {
        // WebGPUを操作するためのAPIを取得
        // 基本的にはwebgpu.hで提供されている関数へのアクセスを提供
        wgpu = WebGPU.GetApi();

        instance = WebGpuInitializeUtil.CreateInstance(window, wgpu);
        surface = WebGpuInitializeUtil.CreateSurface(window, wgpu, instance);
        {
            var adapter = WebGpuInitializeUtil.CreateAdapter(wgpu, instance, surface);
            device = WebGpuInitializeUtil.CreateDevice(wgpu, adapter);
            // デバイス作成後はもうアダプタは不要なので解放
            wgpu.AdapterRelease(adapter);
        }

        WebGpuInitializeUtil.ConfigureSurface(window, wgpu, surface, device);
        WebGpuInitializeUtil.SetErrorCallback(wgpu, device);
    }

    public void Dispose()
    {
        // リソースの解放
        wgpu.SurfaceUnconfigure(surface);
        wgpu.DeviceDestroy(device);
        wgpu.SurfaceRelease(surface);
        wgpu.InstanceRelease(instance);
    }
}

このクラスを使って初期化を行うようProgram.csを修正します。

using Silk.NET.Windowing;
using SilkWgpu;

// 既定値と変更したい値を指定してWindowOptionsを作成
var windowOptions = WindowOptions.Default with
{
    // タイトル
    Title = "Silk.NET WebGPU",
    // グラフィクスAPIを指定しない
    API = GraphicsAPI.None
};

// オプションを指定してウィンドウを作成
var window = Window.Create(windowOptions);
// WebGPU初期化前にウィンドウを初期化する必要がある
window.Initialize();
using WebGpuHandler webGpuHandler = new(window);
webGpuHandler.Initialize();
// ウィンドウを開く
window.Run();

注意点として、「1. ウィンドウの作成」においてはRun内で勝手に呼ばれるということでwindow.Initialize()を呼び出していませんでしたが、WebGPU.GetApi()呼び出し前に初期化が必要になるため明示的に呼び出すよう変更しています。

3. 三角形を表示

ここまででWebGPUの初期化とWindowへの紐付けが完了しましたが、まだ画面には何も映っていません。
このパートでは画面にWebGPUを使って三角形を表示してみます。

3-1. RenderPipelineの作成

画面に描画を行うためには、RenderPipelineが必要です。
WebGpuInitializeUtilにRenderPipelineを作成するメソッドなどを新たに追加します。

// 略

public static class WebGpuInitializeUtil
{

// 略

    // 変更:引数としてTextureFormatを追加、SurfaceConfiguration.UsageにTextureUsage.RenderAttachmentを設定
    public static unsafe void ConfigureSurface(
        IWindow window,
        WebGPU wgpu,
        Surface* surface,
        Device* device,
        TextureFormat textureFormat)
    {
        // 内部的なswap chainの構成を行う
        SurfaceConfiguration configuration = new()
        {
            Device = device,
            // 幅と高さをウィンドウのサイズに合わせる
            // ウィンドウサイズが変更された場合は再設定する
            Width = (uint)window.Size.X,
            Height = (uint)window.Size.Y,
            Format = textureFormat,
            // 先入れ先出し
            PresentMode = PresentMode.Fifo,
            Usage = TextureUsage.RenderAttachment
        };

        wgpu.SurfaceConfigure(surface, configuration);
    }

// 略

    // 追加
    public static unsafe ShaderModule* CreateShaderModule(WebGPU wgpu, Device* device, string shaderPath)
    {
        // 指定されたパスのファイルを読み込む
        var shaderCode = File.ReadAllText(shaderPath);
        // WGSL形式のシェーダーモジュールを作成
        ShaderModuleWGSLDescriptor wgslDescriptor = new()
        {
            Code = (byte*)Marshal.StringToHGlobalAnsi(shaderCode)
        };
        wgslDescriptor.Chain.SType = SType.ShaderModuleWgslDescriptor;

        ShaderModuleDescriptor descriptor = new()
        {
            NextInChain = &wgslDescriptor.Chain
        };

        return wgpu.DeviceCreateShaderModule(device, descriptor);
    }

    // 追加
    public static unsafe RenderPipeline* CreateRenderPipeline(
        WebGPU wgpu,
        Device* device,
        ShaderModule* shaderModule,
        TextureFormat textureFormat,
        string vertexEntryPoint = "main_vs",
        string fragmentEntryPoint = "main_fs")
    {
        VertexState vertexState = new()
        {
            Module = shaderModule,
            EntryPoint = (byte*)Marshal.StringToHGlobalAnsi(vertexEntryPoint)
        };

        BlendComponent blendComponent = new()
        {
            SrcFactor = BlendFactor.One,
            DstFactor = BlendFactor.OneMinusSrcAlpha,
            Operation = BlendOperation.Add
        };
        var blendStates = stackalloc BlendState[1];
        blendStates[0] = new BlendState
        {
            Color = blendComponent,
            Alpha = blendComponent
        };

        var colorTargetState = stackalloc ColorTargetState[1];
        colorTargetState[0] = new ColorTargetState
        {
            WriteMask = ColorWriteMask.All,
            Format = textureFormat,
            Blend = blendStates
        };

        var fragmentState = new FragmentState
        {
            Module = shaderModule,
            EntryPoint = (byte*)Marshal.StringToHGlobalAnsi(fragmentEntryPoint),
            Targets = colorTargetState,
            TargetCount = 1
        };

        RenderPipelineDescriptor descriptor = new()
        {
            Vertex = vertexState,
            Fragment = &fragmentState,
            // MSAAに関する設定
            Multisample = new MultisampleState
            {
                // 全てのサンプルを有効化
                // 0xFFFFFFFFuやuint.MaxValueなどでも同値
                Mask = ~0u,
                // ピクセルごとの計算サンプル数
                Count = 1,
                AlphaToCoverageEnabled = false
            },
            Primitive = new PrimitiveState
            {
                // 裏面をカリング(描画しない)
                CullMode = CullMode.Back,
                // CCW: Counter Clock Wise(反時計回り)
                // 前面の定義を「頂点が反時計回りに並べられている方」とする
                FrontFace = FrontFace.Ccw,
                // 3つの頂点ごとに三角形を描画
                Topology = PrimitiveTopology.TriangleList,
            }
        };

        return wgpu.DeviceCreateRenderPipeline(device, descriptor);
    }
}

2つのメソッドを追加した他、ConfigureSurfaceメソッドの実装の変更も行なっています。

3-2. シェーダーの作成

描画を行うためには、GPUに描画指示を行うためのシェーダーが必要です。 シェーダープログラム用の言語としてはHLSLやGLSLが有名ですが、WebGPUでは標準でWGSLというRustに似た構文を持つ言語を使います。 以下のプログラムをtriangle.wgslとして作成します。
注意点として、少なくともWindows環境ではwgslファイルに日本語コメントを記述するとシェーダーを正しく解釈できずにRust側でnot a valid utf-8 string: Utf8Errorが発生するようです。下記のプログラムには説明のため日本語コメントが含まれていますが、Windows環境で実行する際は日本語コメントを削除してください

// 頂点シェーダー
// 入力: 頂点インデックス
// 出力: 頂点座標
@vertex fn main_vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f
{
    if (index == 0u)
    {
        // 上
        // (x, y) = (0.0, 0.5)
        return vec4f(0.0, 0.5, 0.0, 1.0);
    }
    else if (index == 1u)
    {
        // 左下
        // (x, y) = (-0.5, -0.5)
        return vec4f(-0.5, -0.5, 0.0, 1.0);
    }
    else
    {
        // 右下
        // (x, y) = (0.5, -0.5)
        return vec4f(0.5, -0.5, 0.0, 1.0);
    }
}

// フラグメントシェーダー
// 入力: なし
// 出力: 色
@fragment fn main_fs() -> @location(0) vec4f
{
    // 赤
    // (r, g, b, a) = (1.0, 0.0, 0.0, 1.0)
    return vec4f(1.0, 0.0, 0.0, 1.0);
}

三角形を描画するための簡単なプログラムになっています。
座標系のイメージはこんな感じです。

WebGPUの座標系

参考:WebGPUにおける座標系の詳細な仕様

www.w3.org

このままだとビルド時にシェーダーが含まれず読み込めないため、含むようcsprojを更新します。

<Project Sdk="Microsoft.NET.Sdk">

<!-- 省略 -->

    <ItemGroup>
        <None Update="Shaders\triangle.wgsl">
            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
        </None>
    </ItemGroup>

</Project>

自分はShadersディレクトリにシェーダーを作成したため"Shaders\triangle.wgsl"となっていますが、もし異なれば適宜置き換えてください。

3-3. 描画の実行

やっと描画するところまできました。
作成したシェーダーを使用して描画を行うようWebGpuHandlerを修正します。

using Silk.NET.WebGPU;
using Silk.NET.Windowing;

namespace SilkWgpu;

public sealed unsafe class WebGpuHandler(IWindow window) : IDisposable
{
    private WebGPU wgpu = null!;
    private Instance* instance;
    private Surface* surface;
    private Device* device;
    private RenderPipeline* renderPipeline;

    public void Initialize()
    {
        // WebGPUを操作するためのAPIを取得
        // 基本的にはwebgpu.hで提供されている関数へのアクセスを提供
        wgpu = WebGPU.GetApi();

        instance = WebGpuInitializeUtil.CreateInstance(window, wgpu);
        surface = WebGpuInitializeUtil.CreateSurface(window, wgpu, instance);
        {
            var adapter = WebGpuInitializeUtil.CreateAdapter(wgpu, instance, surface);
            device = WebGpuInitializeUtil.CreateDevice(wgpu, adapter);
            // デバイス作成後はもうアダプタは不要なので解放
            wgpu.AdapterRelease(adapter);
        }

        const TextureFormat textureFormat = TextureFormat.Bgra8Unorm;
        WebGpuInitializeUtil.ConfigureSurface(window, wgpu, surface, device, textureFormat);
        WebGpuInitializeUtil.SetErrorCallback(wgpu, device);
        {
            var shaderModule = WebGpuInitializeUtil.CreateShaderModule(wgpu, device, "Shaders/triangle.wgsl");
            renderPipeline = WebGpuInitializeUtil.CreateRenderPipeline(wgpu, device, shaderModule, textureFormat);
            // RenderPipeline作成後はシェーダーモジュールは不要なので解放
            wgpu.ShaderModuleRelease(shaderModule);
        }

        // ウィンドウのRenderイベントを購読
        window.Render += Render;
    }

    public void Dispose()
    {
        // 購読解除
        window.Render -= Render;
        // リソースの解放
        wgpu.RenderPipelineRelease(renderPipeline);
        wgpu.SurfaceUnconfigure(surface);
        wgpu.DeviceDestroy(device);
        wgpu.SurfaceRelease(surface);
        wgpu.InstanceRelease(instance);
    }

    private void Render(double _)
    {
        SurfaceTexture surfaceTexture = default;
        // 描画する対象のTextureをSurfaceから取得
        wgpu.SurfaceGetCurrentTexture(surface, ref surfaceTexture);
        // TextureからTextureViewを作成
        var textureView = wgpu.TextureCreateView(surfaceTexture.Texture, null);

        var colorAttachments = stackalloc RenderPassColorAttachment[1];
        colorAttachments[0] = new RenderPassColorAttachment
        {
            View = textureView,
            LoadOp = LoadOp.Clear,
            // 青っぽい色
            ClearValue = new Color(0f / 255f, 121f / 255f, 255f / 255f, 1f),
            StoreOp = StoreOp.Store
        };

        RenderPassDescriptor renderPassDescriptor = new()
        {
            ColorAttachments = colorAttachments,
            ColorAttachmentCount = 1
        };

        var commandEncoder = wgpu.DeviceCreateCommandEncoder(device, null);
        // RenderPassを開始
        var renderPassEncoder = wgpu.CommandEncoderBeginRenderPass(commandEncoder, renderPassDescriptor);
        // RenderPassにRenderPipelineをセット
        // RenderPipelineは複数セット可能(異なる図形を描画できる)
        wgpu.RenderPassEncoderSetPipeline(renderPassEncoder, renderPipeline);
        // シェーダーに渡す情報を設定
        // 頂点数3でインデックスは0から
        // 頂点シェーダーがmain_vs(0)、main_vs(1)、main_vs(2)のように呼び出されるようなイメージ
        // 今回の頂点シェーダーの実装ではindex(渡されてきた引数)で条件分岐を行い、対応する画面上の座標を返している(triangle.wgsl参照)
        wgpu.RenderPassEncoderDraw(renderPassEncoder, vertexCount: 3, 1, firstVertex: 0, 0);
        // RenderPassを終了
        wgpu.RenderPassEncoderEnd(renderPassEncoder);
        // ブロック内は順不同
        {
            // RenderPassEncoderEnd後から解放可能:TextureView, RenderPassEncoder
            wgpu.TextureViewRelease(textureView);
            wgpu.RenderPassEncoderRelease(renderPassEncoder);
        }
        // CommandBufferをCommandEncoderから構築
        var commandBuffer = wgpu.CommandEncoderFinish(commandEncoder, null);
        // CommandEncoderFinish後から解放可能:CommandEncoder
        wgpu.CommandEncoderRelease(commandEncoder);
        var queue = wgpu.DeviceGetQueue(device);
        wgpu.QueueSubmit(queue, 1, ref commandBuffer);
        // ブロック内は順不同
        {
            // QueueSubmit後から解放可能:Surface, Queue
            // QueueSubmit後に実行:SurfacePresent
            wgpu.CommandBufferRelease(commandBuffer);
            wgpu.QueueRelease(queue);
            wgpu.SurfacePresent(surface);
        }
        // SurfacePresent後から解放可能:Texture
        wgpu.TextureRelease(surfaceTexture.Texture);
    }
}

主な変更点は以下の通りです。

  • Initializeで新たにRenderPipelineを作成
  • Renderメソッドを追加し、描画処理を記述
  • Renderの呼び出し登録・解除処理をInitializeとDisposeに追加

これで画面に三角形が表示されるようになります。

三角形を表示

参考資料

三角形を描画するまで結構長いコードを書くことになってしまいましたが、基礎的な概念に関しては一通り触れることができたかなと思います。
最後にWebGPUを学ぶ上で参考になる資料を載せておきます。

MozillaによるWebGPU APIの解説記事。実装例はJavaScriptによるものだが、WebGPUの概念に関してわかりやすく解説されている。

developer.mozilla.org

wgpuの実装ガイド。Rustによる実装が学べる。

sotrh.github.io

WebGPUをC++から扱う場合の実装ガイド。

eliemichel.github.io

WebGPU C APIのドキュメント。WIPだが参考になる記事が既に掲載されている。

webgpu-native.github.io

【Unity】NetcodeとEpic Online Services(EOS)で完全無料マルチプレイ(2) - 認証

前回はEOSを導入するための下準備を行いました。
(1)はこちらです。

octo127.hatenablog.com

検証環境

  • Unity 6000.0.28
  • Epic Online Services Plugin for Unity 3.3.4
  • Netcode for GameObjects 2.1.1

1. Epic Online Services Pluginを導入する

まずUnityプロジェクトを作成します。今回の記事ではUnityのバージョンとして6000.0.28を使用しています。 プロジェクトが作成できたら、まずEpic Online Services Pluginを導入してみます。いくつかの方法でインストールできますが、ここではOpenUPM経由でインストールすることにします。 Project SettingsからPackage Managerのタブを開き、Scoped Registryとして以下のように入力し、Applyを押します。

Name OpenUPM(任意の名前で良い)
URL https://package.openupm.com
Scope(s) com.playeveryware.eos

これでPackage ManagerにMy Registriesが追加されます。その後上のメニューバーのWindowからPackage Managerを開き、My Registriesを選択、表示されているEpic Online Services Plugin for Unityをインストールします。

2. EOSのパラメータ設定

メニューバーのTools/EOS Plugin/EOS ConfigurationからEOS Configurationを開きます。

EOS Configuration

入力する必要があるのは以下の項目です。

Product Name netcode-eos-sample(任意の名前)
Product Version 0.1.0(任意の文字列)
Product ID 製品ID
Sandbox ID サンドボックスID
Deployment ID デプロイメントID
Client ID クライアントID
Client Secret クライアントシークレット

Product NameとProduct Versionは任意の文字列を指定できますが、他の5つのパラメータは前回作成したデベロッパーポータルの製品ページからコピー&ペーストする必要があります。 以下のリンクからデベロッパーポータルを開き、ダッシュボードの製品の歯車ボタンから製品設定画面に遷移します。

https://dev.epicgames.com/portal/ja/

ページを下にスクロールして、以下の5箇所のパラメータをコピーしてUnityのEOS Configurationに貼り付けます。

デベロッパーポータルの製品設定ページ

設定が終わった後はそのままウィンドウを閉じると保存されず元に戻ってしまうので、忘れずにSave All Changesを押下します
また、後でデバッグ時のログイン簡略化のためデベロッパー認証ツールというものを使うのですが、これも今のうちにダウンロードしておきます。 先ほどと同じデベロッパーポータルの製品設定ページから「SDKとリリースノート」を選択します。

SDKとリリースノート

今回欲しいのはSDKにおまけで付属してくるデベロッパー認証ツールなのでSDKタイプやバージョンはある程度なんでも大丈夫だとは思いますが、ここではC# SDKと最新バージョンを選択しています。

ダウンロードした後、zipファイルを展開します。DevAuthToolはSDK/Toolsディレクトリに圧縮して置かれていますが、WindowsならEOS_DevAuthTool-win32-x64-1.2.1.zip を、macOSならEOS_DevAuthTool-darwin-x64-1.2.1.zipを展開してください(ファイル名末尾のバージョンは記事投稿時点のものです)。

3. 認証テスト用スクリプトを実装

ここまできてやっとEOSの認証を行うことができるようになります。認証方法はいくつかありますが、最初は一番手軽なDevice Idによる認証を試してみます。
認証用のテストスクリプトを示します。ここではAuthTestという名前でスクリプトを作成します。

using System;
using Epic.OnlineServices;
using Epic.OnlineServices.Connect;
using PlayEveryWare.EpicOnlineServices;
using UnityEngine;

public sealed class AuthTest : MonoBehaviour
{
    private void Start()
    {
        // Device Id作成用のオプションを作成
        var options = new CreateDeviceIdOptions
        {
            DeviceModel = SystemInfo.deviceModel,
        };
        
        // Device Idを作成
        EOSManager.Instance.GetEOSConnectInterface().CreateDeviceId(ref options, null, CreateDeviceCallback);
    }

    // Device Id作成後のコールバック
    private void CreateDeviceCallback(ref CreateDeviceIdCallbackInfo data)
    {
        var displayName = Environment.UserName;
        // Device Idを使ってログイン
        EOSManager.Instance.StartConnectLoginWithOptions(ExternalCredentialType.DeviceidAccessToken, null, displayName,
            OnConnectLoginCallback);
    }

    // ログイン後のコールバック
    private void OnConnectLoginCallback(LoginCallbackInfo loginCallbackInfo)
    {
        // ログイン結果をログに出力
        Debug.Log($"Login result: {loginCallbackInfo.ResultCode}");
    }
}

EOS Pluginパッケージに含まれているEOS Managerとこのスクリプトを適当なシーン(ここではSampleScene)に追加します。
EOS ManagerはEOSに関連するさまざまな機能のインターフェースを提供するシングルトンなMonoBehaviourです。実行時にはDontDestroyOnLoadに移動します。

①EOS Manager

②AuthTest

Playボタンを押した後、コンソールに「Login result: Success」と出ればログイン成功です。

補遺:2回目再生以降の例外ログについて

同様の手順で2回目以降再生を行うと、以下のようなエラーログが出力されると思います。

LogEOSConnect(Error): DeviceId access credentials already exist for the current user profile on the local device.

これは初回の実行でDevice Idが既に作成されているために重複して作成することができなかったことを示していますが、EOSは既にDevice Idが存在する場合は自動的に既存のDevice Idを用いて認証を行うため無視して構いません。EOSからは既にDevice Idがあるかどうかを判定するAPIが提供されていないため、このエラーを避けることは難しいと思われます。もし回避方法についてご存知の方がいらっしゃるようでしたらコメントいただけると幸いです。

さいごに

次回はNetcodeの導入とEOSとの連携を行います。

【Unity】NetcodeとEpic Online Services(EOS)で完全無料マルチプレイ(1) - EOS導入準備

  • Unity 6000.0.28
  • Netcode for GameObjects 2.1.1
  • Epic Online Services Plugin for Unity 3.3.4

概要

NetcodeとEpic Online Services(EOS)を使ってP2Pマルチプレイを実現する方法を複数パートに分けて解説します。専用サーバーを用意してそこにすべてのプレイヤーが接続するというようなサーバークライアント方式は想定せず、あくまでPeer to Peerな実装を考えます。この記事ではEOSの導入準備を行います。

Netcodeについて

Unity公式から提供されているネットワークライブラリで、正式な名前は「Netcode for GameObjects」です。以前はMLAPIという名称で開発されていました(また当初は公式ソリューションでもなかった)が、2021年10月19日の1.0.0-pre.1のリリースからNetcode for GameObjectsに変更されました。正式な名前が長いのはEntities向けのパッケージ「Netcode for Entities」と区別するためですが、この記事では「Netcode for GameObjects」を指して単にNetcodeと呼ぶことにします。

Epic Online Services(EOS)について

Unreal Engineの開発で有名なEpic Games社から提供されているゲーム開発向けのオンラインサービスです。NAT越えP2P通信やマッチメイキング、ボイスチャットなどさまざまな機能がありますが、すべての機能が完全に無料で開放されています。すべて無料な理由についてはEpic Games公式によると、

裏は何もありません。これらのサービスの多くは、『フォートナイト』のために開発されたものあり、現在、莫大なスケールメリットのもとで運用されています。また、Epic Games ストアもこれらのサービスに依存しています。私たちは、Epic の製品をより多くの人たちに使っていただくようにするという目的とともに、クロスプレイ/クロスプログレッションやその他のオープンで相互接続されたオンライン機能をあらゆる人にもっと利用しやすくするという目標を掲げて、ゲーム デベロッパーの方々に無料でこれらのサービスを積極的に提供しています。また、デベロッパーが Epic アカウント サービスの使用を選ぶと、皆が使用できるクロスプラットフォームのアカウント ベースとソーシャル グラフが成長することになり、Epic もすべての参加パートナーも恩恵を受けることができます。

-- Epic Online Services は無料ということですが、その裏には何か仕掛けでもあるのでしょうか?

ということらしいです。 この記事では以降EOSと呼びます。

それではEOSの導入準備を行なっていきます。

1. Epic Gamesアカウントの作成

EOSを導入するためにはEpic Gamesアカウントが必要になります。以下のリンクからアカウントを作成します。もしすでにUnreal Engineを利用していたりEpic Games Storeから配信されているゲームをプレイしたことがあったりする場合はアカウントが既にあると思いますが、その場合EOS導入時点では取り立てて新しいアカウントは必要ありません。ただ複数人プレイを1人でデバッグする場合はアカウントが複数あると便利なので、後から必要になった時はまたアカウントを作ることにします。

https://www.epicgames.com/id/register/date-of-birth

2. 組織の作成

以下のリンクから組織を作成します。組織名は任意ですが、ここでは自分の名前「octo127」としています。 https://dev.epicgames.com/portal/ja/profile/signup

組織名と国を入力

3. 製品の作成

以下のリンクから製品を作成します。この製品は利用するアプリケーションごとに作成するものです。ここでは仮に「netcode-eos-sample」とします。

https://dev.epicgames.com/portal/ja/

リンクやや下方に「製品を作成」ボタンがある

任意の製品名を入力

作成ボタンを押してしばらく待つと、ダッシュボードに製品が追加されます。

追加された製品

4. 製品の設定

ダッシュボードの製品の歯車ボタンから製品設定画面に遷移します。

製品設定

クライアントを作成します。クライアントタブを押下することでクライアントセクションに移動できますが、最初に契約への同意を求められます。

タブからクライアントを選ぶ

契約に同意しなければ遷移できない

契約画面はこんな感じです。契約を下までスクロールして最後まで読むと、承諾ボタンが押せるようになります。

契約画面

承諾後はクライアントセクションに移動できるようになります。

クライアントセクション

「新規クライアントの追加」を選択し、クライアント作成画面を開きます。必要な設定項目は2つで、クライアント名とクライアントポリシーです。クライアント名は任意の名前を設定します。

クライアント作成画面

「新規クライアントポリシーの追加」を選択し、クライアントポリシーを作成します。今回はP2P通信機能を利用したいので、クライアントポリシーの種類は「Peer2Peer」とします。

クライアントポリシーの作成

これでクライアントが作成できました。

新規クライアントを追加ボタンで編集を終了

さいごに

ここまででEOSの導入準備はできました。次回はUnity上でEOSのセットアップを行います。

【Unity】レイヤーをドロップダウンで選択できる属性を作る

Unity 6000.0.28

レイヤーの取り扱い

Unityにおいてレイヤーはint型として扱います。 レイヤーを表現するためにint型をラップするような型が提供されているわけではないためインスペクタ等から設定したいような場合、指定したいレイヤー名に対応するインデックスを確認する必要がありますし、不正値が入力される可能性もあります。 そこで、今回はレイヤー名が割り当てられているインデックスのみをドロップダウンで選択できるようにする属性を作ってみます。

(補足)LayerMaskについて

レイヤー機能に関連する型としてはLayerMaskがあり、これはインスペクタ上で表示対応がなされていますが、レイヤーそのものではなくレイヤーでビットマスクした結果が格納される型になっています。

using UnityEngine;

public class LayerAndLayerMask : MonoBehaviour
{
    // レイヤー:単一のレイヤーを指定
    public int layer = 6;
    // レイヤーマスク:複数のレイヤーによってマスクを生成
    // 例:Alphaレイヤー(インデックス:6)とBetaレイヤー(インデックス:7)のみをマスクする場合
    // 演算:1 << 6 | 1 << 7
    // 結果:00000000 00000000 00000000 11000000
    public LayerMask layerMask = 1 << 6 | 1 << 7;
}

レイヤーとレイヤーマスク

実装

レイヤーをドロップダウンで選択できるようにする属性LayerAttributeの実装を示します。

using System;
using System.Linq;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

[AttributeUsage(AttributeTargets.Field)]
public class LayerAttribute : PropertyAttribute
{
#if UNITY_EDITOR
    [CustomPropertyDrawer(typeof(LayerAttribute))]
    private class LayerAttributeDrawer : PropertyDrawer
    {
        public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
        {
            if (property.propertyType is not SerializedPropertyType.Integer) return;

            var layers = Enumerable.Range(0, 32)
                .Select(i => (index: i, name: LayerMask.LayerToName(i)))
                .Where(tuple => !string.IsNullOrEmpty(tuple.name))
                .ToArray();

            var currentIndex = property.intValue;
            var currentArrayIndex = Array.FindIndex(layers, tuple => tuple.index == currentIndex);

            layers = new[] { (-1, "NO MATCH") }.Concat(layers).ToArray();
            currentArrayIndex++;

            var layerNames = layers.Select(tuple => tuple.name).ToArray();

            // ドロップダウンメニューを表示
            var nextArrayIndex = EditorGUI.Popup(position, label.text, currentArrayIndex, layerNames);
            if (nextArrayIndex == 0)
            {
                nextArrayIndex = currentArrayIndex;
            }

            property.intValue = layers[nextArrayIndex].index;
        }
    }
#endif
}

使い方

レイヤーを指定したいint型のフィールドに[Layer]属性をつけることで、ドロップダウンで選択できるようになります。

using UnityEngine;

public class LayerAttributeTest : MonoBehaviour
{
    [Layer] public int layer;
}

レイヤーのドロップダウン表示

すでにそのフィールドに不正な値が代入されている場合はNO MATCHと表示されるようになっていますが、一度レイヤーをドロップダウンから選択するとNO MATCHは選べなくなります。

さいごに

レイヤーを一々整数で設定するのは面倒だと思うので、ぜひ使ってみてください。

【Unity】シーンの名前をドロップダウンで選択できるようになる属性を作る

Unity 6000.0.28

シーン名の取り扱い

UnityにおいてSceneManager.LoadSceneなどに渡す際、シーンの名前は通常のstring型として扱います。

using UnityEngine;
using UnityEngine.SceneManagement;

public class SceneNameTest : MonoBehaviour
{
    public string sceneName;

    private void Start()
    {
        SceneManager.LoadScene(sceneName);
    }
}

このようにMonoBehaviourのフィールド等でシーン名を指定する場合インスペクタの入力でタイポしていても気付きにくく、タイポしたままだと実行時に例外が発生してしまいます。

SampleSceneをSampleSeneとタイポしている

また、ビルド設定のScenes In Buildに含め忘れていて遷移できないということもしばしば発生すると思います。

そこで今回は、シーンの名前をScenes In Buildに含まれているシーンの中からドロップダウンで指定できるようにする属性を作ってみます。

実装

using System;
using System.Linq;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

[AttributeUsage(AttributeTargets.Field)]
public class SceneNameAttribute : PropertyAttribute
{
#if UNITY_EDITOR
    [CustomPropertyDrawer(typeof(SceneNameAttribute))]
    private class SceneNameAttributeDrawer : PropertyDrawer
    {
        public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
        {
            if (property.type != "string") return;

            var propertyValue = property.stringValue;
            var sceneNames = EditorBuildSettings.scenes
                .Select(scene => scene.path)
                .Select(scenePath => scenePath.Split('/').Last().Replace(".unity", ""))
                .ToArray();
            var currentIndex = Array.IndexOf(sceneNames, propertyValue);
            sceneNames = new[] { "NO MATCH" }.Concat(sceneNames).ToArray();
            currentIndex++;

            // ドロップダウンメニューを表示
            var nextIndex = EditorGUI.Popup(position, label.text, currentIndex, sceneNames);
            if (nextIndex == 0)
            {
                nextIndex = currentIndex;
            }

            property.stringValue = sceneNames[nextIndex];
        }
    }
#endif
}

使い方

シーンの名前を設定したいstring型のフィールドに[SceneName]属性をつけることで、ドロップダウンで選択できるようになります。

using UnityEngine;
using UnityEngine.SceneManagement;

public class SceneNameTest : MonoBehaviour
{
    [SceneName] public string sceneName;

    private void Start()
    {
        SceneManager.LoadScene(sceneName);
    }
}

インスペクタ上の表示は以下のようになります。

Scene ListにないSceneBは表示されていない

選択対象となるシーンはEditorBuildSettings.scenesのみなので、Scenes In Build(Unity 6のためScene Listに表記が変わっています)にないシーンはドロップダウンに表示されていません。 またすでにそのフィールドに不正な値が代入されている場合はNO MATCHと表示されるようになっていますが、一度シーン名をドロップダウンから選択するとNO MATCHは選べなくなります。

さいごに

シーン名のタイポやScenes In Buildへの追加忘れは単純なミスですが結構起こりがちだと思います。ちょっとした手間である程度これが防げるようになるので、ぜひ使ってみてください。

【C#】Enum(列挙型)を以前の名前を考慮してパースする

列挙型のパースとシリアライズ

stringを列挙型に変換したいときは、通常Enum.Parseメソッドを使います。型を引数で渡すものとジェネリック版とがありますが使い方は大体一緒です。

using System;

public enum EnumSample
{
    None,
    Alpha,
    Bravo,
    Charlie,
    Delta,
}

public static class EnumParseTest
{
    public static void Test()
    {
        EnumSample enumSample = (EnumSample)Enum.Parse(typeof(EnumSample), "Alpha");
        // Alphaが出力
        Console.WriteLine(enumSample);

        // ジェネリック版
        EnumSample enumSampleGeneric = Enum.Parse<EnumSample>("Alpha");
        // Alphaが出力
        Console.WriteLine(enumSampleGeneric);
    }
}

文字列から列挙型への変換を行いたいシチュエーションとしては、例えば以下の記事のように列挙型のメンバーの名前をシリアライズしているような状況が考えられます。

octo127.hatenablog.com

ただこの場合、開発中にシリアライズした時点の名前からメンバー名を変更すると当然ですがParseできなくなってしまいます。

using System;

public enum EnumSample
{
    None,
    // 名前をAlphaからAppleに変更
    Apple,
    Bravo,
    Charlie,
    Delta,
}

public static class EnumParseTest
{
    public static void Test()
    {
        EnumSample enumSample = (EnumSample)Enum.Parse(typeof(EnumSample), "Alpha");
        // ArgumentException: Requested value 'Alpha' was not found.
        Console.WriteLine(enumSample);
    }
}

そこでこの記事では、以前の名前も考慮して列挙型をパースできる機能を実装します。
最初に通常の列挙型に対する実装を示し、その後[Flags]属性が付加されたビットフィールドとしての列挙型のパースを考えてみることにします。

実装

FormerlyNameAttribute

まず、以前の名前を記録する属性としてFormerlyNameAttributeを作ります。

using System;

[AttributeUsage(AttributeTargets.Field, AllowMultiple = true)]
public sealed class FormerlyNameAttribute : Attribute
{
    public string oldName { get; }
    public FormerlyNameAttribute(string oldName) => this.oldName = oldName;
}

提供する機能がUnityのFormerlySerializedAsAttributeと似ているため、実装もほぼ一緒です。

docs.unity3d.com

シンプルなパーサ

一旦Flagsを考慮しないパーサを作ります。

using System;

public static class EnumParseUtility
{
    public static Enum Parse(Type enumType, string value)
    {
        var currentName = GetCurrentName(enumType, value);
        return (Enum)Enum.Parse(enumType, currentName);
    }

    // ジェネリック版
    public static T Parse<T>(string value) where T : Enum
    {
        return (T)Parse(typeof(T), value);
    }

    // 現在のメンバー名を取得
    public static string GetCurrentName(Type enumType, string value)
    {
        var names = Enum.GetNames(enumType);
        foreach (var name in names)
        {
            if (name.Equals(value, StringComparison.OrdinalIgnoreCase))
            {
                return name;
            }
        }

        foreach (var field in enumType.GetFields())
        {
            // メンバーに付加されているFormerlyNameAttributeを取得
            var attributes = (FormerlyNameAttribute[])field.GetCustomAttributes(typeof(FormerlyNameAttribute), false);
            foreach (var attribute in attributes)
            {
                if (attribute.oldName.Equals(value, StringComparison.OrdinalIgnoreCase))
                {
                    return field.Name;
                }
            }
        }

        // 該当するメンバーが存在しない場合は例外をスロー
        throw new ArgumentException($"'{value}' is not a member of the enum '{enumType.FullName}'");
    }
}

最初に現在のメンバー名に対して一致するものがあるかチェックした後、もしなければメンバーごとに付加されているFormerlyNameAttributeを取得し、その中で一致しているものがあるか再度判定を行うという単純な実装になっています。

Flagsを考慮したパーサ

Flagsを考慮する場合、標準のEnum.Parseがパースできるのはカンマ区切りで

Apple, Bravo

のような形式なので、ここではそれに倣うことにします。
先ほどのEnumParseUtilityクラスに以下のParseFlagsメソッドを追加します。

// ファイルの先頭に追加
using System.Linq;

// 略

// Flagsの付加された列挙型をパースするメソッド
public static Enum ParseFlags(Type enumType, string value)
{
    // カンマで分割し、前後の空白を除去
    var splitValues = value.Split(',').Select(v => v.Trim());
    int flags = 0;
    foreach (var splitValue in splitValues)
    {
        var currentName = GetCurrentName(enumType, splitValue);
        flags |= (int)Enum.Parse(enumType, currentName);
    }

    return (Enum)Enum.ToObject(enumType, flags);
}

Parseメソッドを以下のように修正します。

public static Enum Parse(Type enumType, string value)
{
    // enumTypeがフラグを持つかどうかを確認
    if (enumType.GetCustomAttributes(typeof(FlagsAttribute), false).Length > 0)
    {
        return ParseFlags(enumType, value);
    }

    var currentName = GetCurrentName(enumType, value);
    return (Enum)Enum.Parse(enumType, currentName);
}

完成形

完成形のスクリプトになります。

using System;
using System.Linq;

public static class SimpleEnumParseUtility
{
    public static Enum Parse(Type enumType, string value)
    {
        // enumTypeがフラグを持つかどうかを確認
        if (enumType.GetCustomAttributes(typeof(FlagsAttribute), false).Length > 0)
        {
            return ParseFlags(enumType, value);
        }

        var currentName = GetCurrentName(enumType, value);
        return (Enum)Enum.Parse(enumType, currentName);
    }

    public static Enum ParseFlags(Type enumType, string value)
    {
        // カンマで分割し、前後の空白を除去
        var splitValues = value.Split(',').Select(v => v.Trim());
        int flags = 0;
        foreach (var splitValue in splitValues)
        {
            var currentName = GetCurrentName(enumType, splitValue);
            flags |= (int)Enum.Parse(enumType, currentName);
        }

        return (Enum)Enum.ToObject(enumType, flags);
    }

    // ジェネリック版
    public static T Parse<T>(string value) where T : Enum
    {
        return (T)Parse(typeof(T), value);
    }

    // 現在のメンバー名を取得
    public static string GetCurrentName(Type enumType, string value)
    {
        var names = Enum.GetNames(enumType);
        foreach (var name in names)
        {
            if (name.Equals(value, StringComparison.OrdinalIgnoreCase))
            {
                return name;
            }
        }

        foreach (var field in enumType.GetFields())
        {
            // メンバーに付加されているFormerlyNameAttributeを取得
            var attributes = (FormerlyNameAttribute[])field.GetCustomAttributes(typeof(FormerlyNameAttribute), false);
            foreach (var attribute in attributes)
            {
                if (attribute.oldName.Equals(value, StringComparison.OrdinalIgnoreCase))
                {
                    return field.Name;
                }
            }
        }

        // 該当するメンバーが存在しない場合は例外をスロー
        throw new ArgumentException($"'{value}' is not a member of the enum '{enumType.FullName}'");
    }
}

補遺:アロケーションについて

このパーサは配列の確保やボクシングなどを行うため、メモリアロケーションが多く発生しています。チューニング可能な箇所はいくつかあると思いますが、このままの実装で例えばUnityのUpdateのような高頻度かつ実行速度が必要な環境で実行するのは控えた方が良いかもしれません。

【Unity】Enum(列挙型)を文字列としてシリアライズする属性を作る

列挙型シリアライズの挙動

Unityでは列挙型の値はC#の内部的な扱いと同様、int型の整数値としてシリアライズされます。 この挙動は列挙型のメンバー(列挙子)を入れ替えた場合や、既存のメンバーの間に挿入する形で新しいメンバーを追加した場合、問題を引き起こすことがあります。

例として、以下のような列挙型と、それをフィールドとして持つMonoBehaviourがあるとします。

public enum EnumSample
{
    None,
    Alpha,
    Bravo,
    Charlie,
    Delta,
}
using UnityEngine;

public sealed class EnumTest : MonoBehaviour
{
    public SampleEnum sampleEnum;
}

Unityインスペクタ上ではこれは次のように表示されます。

Unityインスペクタ上の表示

次に、NoneとAlphaの順序を入れ替えてみます。

public enum EnumSample
{
    // NoneとAlphaを入れ替え
    Alpha,
    None,
    Bravo,
    Charlie,
    Delta,
}

するとシーンファイルを何も編集せずとも、表示がNoneからAlphaに変わりました。

NoneとAlphaを入れ替えた後

これは列挙型が実質的にはint型として扱われていることで起こる挙動です。 実際にシーンファイルの中身を覗いてみると、以下のようにシリアライズされています。

  enumSample: 0

0に割り当てられている列挙子がNoneからAlphaに変更されたので、インスペクタ上の表示が変わったというのが原理です。

この記事では列挙子の値そのものではなく、列挙子の名前をstring型でシリアライズすることで、順序を入れ替えたり途中に列挙子を追加したりしてもシリアライズした値が変わらないような属性を作ってみることにします。

実装

シリアライズしたい列挙型を指定できる属性EnumStringAttributeと、それを描画するPropertyDrawerを実装を示します。

#nullable enable
using System;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

[AttributeUsage(AttributeTargets.Field)]
public sealed class EnumStringAttribute : PropertyAttribute
{
    private readonly Type? enumType;

    public EnumStringAttribute(Type enumType)
    {
        this.enumType = enumType;
    }

#if UNITY_EDITOR
    [CustomPropertyDrawer(typeof(EnumStringAttribute))]
    private class EnumStringAttributeDrawer : PropertyDrawer
    {
        private const string helpMessage = nameof(EnumStringAttribute) + " can only be used on string fields";

        private string[]? enumNames;

        public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
        {
            if (property.propertyType is not SerializedPropertyType.String)
            {
                // string型以外の場合はエラーメッセージを表示
                EditorGUI.HelpBox(position, helpMessage, MessageType.Error);
                return;
            }

            var propertyValue = property.stringValue;
            var enumStringAttribute = (EnumStringAttribute)attribute;

            // enumTypeがEnum型の派生型であることを確認
            if (enumStringAttribute.enumType is null || !enumStringAttribute.enumType.IsEnum)
            {
                // enumTypeがnullまたはEnum型の派生型でない場合は通常のPropertyFieldを表示
                EditorGUI.PropertyField(position, property, label);
                return;
            }

            if (enumNames is null)
            {
                // Enumの名前配列の先頭に不正値を表す"INVALID VALUE"を追加
                var enumNameDefaults = Enum.GetNames(enumStringAttribute.enumType);
                enumNames = new string[enumNameDefaults.Length + 1];
                enumNames[0] = "INVALID VALUE";
                Array.Copy(enumNameDefaults, 0, enumNames, 1, enumNameDefaults.Length);
            }

            var currentIndex = Array.IndexOf(enumNames, propertyValue);
            // シリアライズされている値がenumNamesに存在しない場合は0に設定
            currentIndex = currentIndex == -1 ? 0 : currentIndex;

            // ドロップダウンメニューを表示
            var nextIndex = EditorGUI.Popup(position, label.text, currentIndex, enumNames);
            if (nextIndex == 0)
            {
                // "INVALID VALUE"を後から選択できないようにcurrentIndexに戻す
                nextIndex = currentIndex;
            }

            property.stringValue = enumNames[nextIndex];
        }
    }
#endif
}

使い方

この属性を用いて列挙型の値をシリアライズしたい場合、以下のように属性をフィールドに付加して宣言します。

using UnityEngine;

public class EnumStringSerializeTest : MonoBehaviour
{
    [EnumString(typeof(EnumSample))] public string enumSampleString;
}

何も代入されていなかったり、列挙子として宣言していない文字列が代入されていた場合はINVALID VALUEと表示されます。 通常の列挙型と同じように、ポップアップで値を選択できます。 一度INVALID VALUE以外の値を選択した後は、インスペクタからはINVALID VALUEを選択することができなくなります。

不正値の取り扱いについて

前項でも述べたとおり、シリアライズしているのがstring型である以上、列挙子として宣言していない文字列が代入されることが考えられます。不正値をどのように扱いたいかは状況によって変わるかと思いますが、今回の実装では不正値は列挙型の規定値に合わせる等はせず、独立した値として取り扱っています。

また例示した実装では不正値をINVALID VALUEで表していますが、勿論これはどのような値でも構いません。ただINVALID VALUEInvalidValueのようなメンバー名としても使用できる文字列だと実際のenum型のメンバー名と重複してしまう可能性があるため、この例のように空白を入れたり、[INVALID_VALUE]のようにメンバー名として使用できない文字を入れたりした方が良いと思います。EditorGUI.Popupは指定した配列の要素に重複があった場合最初の要素のみ表示するため、もし仮に重複すると表示する順序が前後してしまう可能性があります。