dotnetコマンドでdllを作成しUnityで使う

はじめに

アセットストアなどでスクリプトのアセットを入れると C#のコードではなくdllが入っていることがある。

ProBuilderのdll

ProBuilderのdll

この記事では自分のコードをdllにしてUnityから使ってみる。

Unityのバージョン: 2018.2.7f1

.NETについて

今回扱うのはネイティブではなくてManagedなdllのほう。 マネージコードはCILにコンパイルされて.NET Frameworkの実行基板上で動作する。 まずはCILや.NETなどについて説明する。

.NET Frameworkから説明していく。 .NET FrameworkはMicrosoftが開発している、ソフトウェア開発・実行基盤のひとつ。 WindowsにくっついてくるのでWindowsアプリの開発実行環境として使われる。 2002年にリリースされた。

この.NET FrameworkではOSやコンピュータに依存しない中間言語の仕様が定義されているそうだ。 この中間言語はCIL(Common Intermediate Language)と呼ばれる。 .NET Frameworkでは中間言語はJITコンパイラで実行時にネイティブコードへ変換される。 各言語のコンパイラは中間言語への変換を行うことで プラットフォームごとに実行ファイルを用意する必要がなくなる。 C#やF#などの言語はコンパイラによってこのCILに変換されている。 コンパイルの生成物のexeファイルやdllファイルの中身はこのCILである。 このexeやdllのことをまとめて「アセンブリ」と呼ぶ。

また、.Net Frameworkには標準ライブラリが含まれている。 using System;などと自分で定義していないのにusingできるクラスは この標準ライブラリで定義されている。

言語の実行基盤の方は大きく変更されていないので、 .NET Frameworkのバージョンというと基本的には含まれる標準ライブラリのことになる。 どのクラスやメソッドが使えるかというのが、 実行する.NET Frameworkのバージョンによって決まってくると考えておけば概ね問題ない。 文脈によっては.NET Frameworkと言ったときに、CILのことは忘れて 標準ライブラリ群そのもののことを指すことさえあるようだ。

.NET Frameworkの仕様はEcma InternationalやISO/IECによって標準化され、公開されている。 そのため、Microsoft以外が.NET Frameworkと互換性のある環境を作ることも可能だ。 そうして作られたものが「Mono」である。 Monoは.NET互換で、Linux上で実行できる。 .NET FrameworkはWindows専用なのでLinuxやmac OSなどではこのMonoを使っていた。

Unityはクロスプラットフォームで動かすスクリプティング環境に.NETとMonoをベースとしている。 ゲームエンジン本体はC++で書かれているそうなので、あくまでユーザスクリプトの実行環境の話だ。

Unityでは長らく.NET Frameworkのバージョン3.5と同程度の環境だった。 つまり、Unityでは.NET Framework 3.5に含まれる標準ライブラリしか使えなかったということだ。 .NET Framework 3.5の標準ライブラリにはTaskクラスなどが含まれていないため、 UnityのC#ではasync/awaitなどの機能が使えなかった。

Unityの「Edit > Project Setting > Player」から「Other > Configuration > Scripting Runtime Version」設定を変えることで.NET Framework 4.xの環境に変えられる。

これでasync/awaitなどの機能も使えるようになった。 余談だがUnityでasync/awaitを使う場合は.NETのTaskクラスよりもUniRxのUniTaskを使ったほうがよいようだ

Scripting Runtime Versionを変更した

ちなみにUnityでは.NET Frameworkのバージョンだけでなく、 コンパイラに渡すパラメータでコンパイルするC#のバージョンに制限を加えている。 async/awaitを記述するとエラーになるのは直接的にはこのパラメータによるものだ。 新しい言語機能を使うだけで新しい標準ライブラリの機能を使わないならば、 新しい言語機能を古い環境向けにコンパイルしても概ね動く。 コンパイル作業を自分で行えば標準ライブラリに依存しない言語機能を古い環境で使うことも可能だ。


.NET Frameworkから時代が下り、2014年11月に.NET Coreというものが発表される。 .NET FrameworkからWindows専用の機能を削ってクロスプラットフォーム対応で オープンソースとして公開された。 .NET FrameworkはWindows専用だったが、.NET CoreはLinuxやmac OSでも動作する。 .NET FrameworkからWPFやWindows FormsなどのWindows向けアプリのライブラリが削られている。 主にサーバーサイドでの利用を想定されて開発されたようだ。

.NET Coreと.NET Frameworkには共通する部分も多い。 そこでその共通部分をきちんと定義した.NET Standardというのも登場する。

UnityではUnity 2018.1から.NET Standard 2.0にも対応している。 「Edit > Project Setting > Player」から「Other > Configuration > Api Conpatibility Level」で.NET Standard 2.0に変更可能だ。

.NETとC#のバージョン

上にも書いたがC#の言語のバージョンと.NETの言語のバージョンは独立している。

たとえばC# 6.0で追加された?.などは標準ライブラリに依存していないため コンパイルしたILを古い環境で動かすこともできる。

一方で標準ライブラリに依存する機能は古い環境では使うことはできない。 たとえばasync/awaitやTupleなど。 これはコンパイルによって標準ライブラリの呼び出しに変換される。 新しい標準ライブラリの入っていない環境ではこれらの機能は利用できない。

どの機能がどの.NETのバージョンで利用できるかはこちらのページが詳しい。

また、.NET Coreや.NET Standardの各バージョンが .NET Frameworkのどのバージョンにあたるのかはこちらのページに書かれている。

自分のコードをdll化する利点

コードをdll化してUnityで読み込む手順を説明する前に、 自分のコードをdll化する利点についてあげておく。 中には現在は別の方法を取ったほうがよいものもあるので、それについても書いておく。


まず1つ目はコンパイル時間の短縮だ。

スクリプトは基本的に全部ひとつの というアセンブリにまとめられる。 別アセンブリに分離して、あらかじめコンパイルしておくことでエディタ上でのコンパイルを減らせる。

現在はAssemblyDefinitionがあるのでこの用途でdllを作るのはあまりお勧めしない。


2つ目の利点はあたらしいC#の機能が使えること。

上でも述べたとおり、C#の言語機能と標準ライブラリのバージョンは独立しているので、 新しい言語機能を古い環境向けにコンパイルしても概ね動く

Unityの.NET環境が3.5で止まっていた関係で、 UnityのC#のバージョンは長らく4.0に設定されていた。 自前でコンパイルすることで新しい言語の機能を使うことができた。

ただし、現在は最新のC#を使う目的ならば自分でコンパイル作業をする必要はないようだ。 Unity Package ManagerからIncremental Compilerを入れることで C#の最新のバージョンも使えるようになる。 さらに現在Beta版のUnity 2018.3からはC# 7.2が使えるようになったらしい。


3つ目はF#などの他の言語を利用できること。

UnityのエディタのコンパイラはF#などのC#以外の.NET対応言語に対応していないので、 それらの言語を使う場合には自前でコンパイルする必要がある。


4つ目は難読化するためにdll化するというもの。

アセットストアに出品する場合にはC#のプログラムをそのまま置きたくないこともあるかもしれない。 そういったときにあらかじめdll化してから配布するといった方法が使われているようだ。

しかし、ほんとうの意味で難読化のためにdll化するのはお勧めできない。 ILは難読化を目的にしたものではない。 ILをデコンパイルするプログラムもあるようだ。

dllを解析することを禁じる、という文言を利用規約に加えておくことでよしとするならば よいかもしれない。

C#のコードはUnityのエディタでプレビューされてしまうが、 dllならばプレビューされないからそれだけでも意味があるともいえる。

dllを作成しUnityで読み込む

ここからは実際にdllを作成してUnityで読み込むところまでをやってみる。

dotnetコマンド

.NET向けのプログラムを作成するにはdotnetコマンドをインストールすると便利だ。

.NET Core Command-Line Interface (CLI) Tools | Microsoft Docs

このdotnetコマンドがインストールされている前提で話を進めていく。

まずはコンソールアプリを作ってみよう。 適当なディレクトリで次のコマンドを実行する。

dotnet new console

すると次のようなファイルが作成される。

<root>
├── obj/
├── Program.cs
└── <project>.csproj

Program.csの中身は次のとおりだ。

using System;

namespace testDotnet
{
  class Program
  {
    static void Main(string[] args)
    {
        Console.WriteLine("Hello World!");
    }
  }
}

これを実行するには次のコマンドを実行する。

dotnet run

すると次のように表示されるはずだ。

Hello World!

このdotnet runコマンドはビルドから実行までをひとまとめでやってくれる。 ビルドだけを行うなら次のコマンドを実行する。

dotnet build

これで<root>/bin/Debug/netcoreapp2.1<project>.dllが生成される。 リリースビルドを行う場合は次のコマンドを実行すればよい。

dotnet build -c Release

このdotnet buildの実態はMSBuildと呼ばれるビルドツールだ。 dotnet buildコマンドを実行すると MSBuildは<project>.csprojに書かれたビルドタスクを実行する。 簡単にいうとmakeみたいなものだ。

dotnet new consoleで作られた<project>.csprojの中身を見てみよう。

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.1</TargetFramework>
  </PropertyGroup>

</Project>

csprojはxml形式であることが分かる。 MSBuildのタスクはxml形式で記述される。

一番最初に<Project Sdk="Microsoft.NET.Sdk">と書かれている。 このProjectSdk属性を指定するとSDKのデフォルトのビルドタスクが読み込まれる。 これはSDK Basedと呼ばれているそうだ。 このcsprojにはコンパイルするタスクが見当たらないのはそういった理由である。

SDK Basedではないcsprojを見てみよう。 試しにVisualStudio2017で.NET Framework向けのC#のプロジェクトを立ち上げて csprojを見てみる。

新しくプロジェクトを立ち上げる

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
  <PropertyGroup>
    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
    <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
    <ProjectGuid>ff59245d-29c5-41d6-803b-6045ce9a46b4</ProjectGuid>
    <OutputType>Library</OutputType>
    <AppDesignerFolder>Properties</AppDesignerFolder>
    <RootNamespace>ClassLibrary1</RootNamespace>
    <AssemblyName>ClassLibrary1</AssemblyName>
    <TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
    <FileAlignment>512</FileAlignment>
  </PropertyGroup>
  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
    <DebugSymbols>true</DebugSymbols>
    <DebugType>full</DebugType>
    <Optimize>false</Optimize>
    <OutputPath>bin\Debug\</OutputPath>
    <DefineConstants>DEBUG;TRACE</DefineConstants>
    <ErrorReport>prompt</ErrorReport>
    <WarningLevel>4</WarningLevel>
  </PropertyGroup>
  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
    <DebugType>pdbonly</DebugType>
    <Optimize>true</Optimize>
    <OutputPath>bin\Release\</OutputPath>
    <DefineConstants>TRACE</DefineConstants>
    <ErrorReport>prompt</ErrorReport>
    <WarningLevel>4</WarningLevel>
  </PropertyGroup>
  <ItemGroup>
    <Reference Include="System"/>

    <Reference Include="System.Core"/>
    <Reference Include="System.Xml.Linq"/>
    <Reference Include="System.Data.DataSetExtensions"/>


    <Reference Include="Microsoft.CSharp"/>

    <Reference Include="System.Data"/>

    <Reference Include="System.Net.Http"/>

    <Reference Include="System.Xml"/>
  </ItemGroup>
  <ItemGroup>
    <Compile Include="Class1.cs" />
    <Compile Include="Properties\AssemblyInfo.cs" />
  </ItemGroup>
  <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
 </Project>

参照する標準ライブラリ群が記述されているのが分かる。 実際のビルドタスク自体は他の場所からインポートしているようだ。 SDK Basedにしないとこれだけの大量の記述が必要になる。 SDK Basedにすることで、プロジェクトで共通的に使われるものを 記述しなくて済むのでcsprojをシンプルにできる。


dotnetコマンドではNuGetからパッケージを持ってきて利用できる。 依存するパッケージを追加してみよう。 試しにMath.NET Numericsを追加してみる。 次のコマンドを実行する。

dotnet add <project>.csproj package MathNet.Numerics

dotnet sln add <PROJECT> package <PACKAGE_NAME>コマンドで プロジェクトにMathNet.Numericsを追加している。

csprojに依存するパッケージが書き込まれているのが確認できる。

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="MathNet.Numerics" Version="4.5.1" />
  </ItemGroup>

</Project>

csを次のように書き換えてみる。

using System;
using MathNet.Numerics.LinearAlgebra;

namespace testDotnet {
  class Program {
    static void Main (string[] args) {
      Console.WriteLine ("Hello World!");

      var matrix = Matrix<double>.Build.Random (100, 100);
      var vector = Vector<double>.Build.Random (100);
      var y = matrix.Solve (vector);
      Console.WriteLine (y);
    }
  }
}

これで実行をしてみよう。

dotnet run

すると次のように表示されるはずだ。

Hello World!
DenseVector 100-Double
 0.892551    1.79533    2.21426  0.0873945  -0.556335     0.520973  -0.250902
 -1.88569   -2.25393   -0.67199  -0.211079  -0.775983     -2.80562  -0.311818
0.0579979  -0.976454    0.75983   0.529851   -1.99296  -0.00513427  -0.546389
  1.73797   0.526036   0.905534  -0.610665   -2.59059    -0.230172  0.0802672
-0.240365     1.0561    1.31423   -2.28516  -0.945354     -1.17448   -1.32992
 -3.61025    1.59111    1.25329     1.3431  -0.516505      1.19649  -0.197032
 -1.56407    1.53583    1.53264   0.583491   0.722533     0.259483   -0.10203
 -1.89817   0.606906  -0.714441  -0.150727  -0.760899     0.253868   0.325484
 -1.46133   -1.04354    1.72711  -0.621516   0.693353     -0.91116   -1.07632
-0.401632   -2.20171   -1.06723    0.39541    1.68322    -0.995609    1.44723
  2.02817    0.43478   0.770709  -0.908916    1.12304     0.178831         ..
 -1.42697    1.03932    1.35466    1.48224    1.29802    -0.389232  -0.845294

NuGetのパッケージを利用したプログラムが無事実行できていることが確認できた。


現在のdotnetコマンドのバージョンは2だが、dotnetコマンドのバージョンが1の頃は csprojではなくてproject.jsonというものを使っていたらしい。 MSBuildが大きくなりすぎているから、新しいビルドツールを作ろうとしたようだ。

しかし、dotnet v2になって、MSBuildをシンプルに書ける方向で進化させることになった。 SDK Basedはそういった試みの一環で付け加えられた機能のようだ。 dotnet v2ではproject.jsonは非推奨となっている。 古いdotnetコマンドについての記事を見るとproject.jsonをベースにしていることがあるので 注意が必要。

参考:https://ufcpp.net/blog/2017/5/newcsproj/

Unity向けのdllを作る

こんどはUnity向けにdllを作ってみよう。 dotnet newコマンドで新しくプロジェクトを作る。 場所はどこでもよいが、今回はUnityのプロジェクトルート直下、Assets/と 同じ階層に適当な名前でディレクトリを作って開発を進める。

<UnityProjectRoot>
├── Assets/
├── Library/
├── ProjectSettings/
├── UnityPackageManager/
├── UnityDllTestProject/
└── <UnityProjectName>.sln

dotnet newコマンドの引数によって使用するテンプレートを指定できる。 これによって適切なcsprojを生成してくれるというものだ。 さきほどはconsoleを指定することでコンソールアプリの テンプレートのcsprojを生成してもらった。 Unity向けのテンプレートは存在しないので結局手作業でcsprojを弄る必要があるから 適当なテンプレートで作ってしまってよい。 余計なものの少ないclasslibあたりで作っておくと楽だろう。

dotnet new classlib

これで次のようなcsprojが生成されたはずだ。

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

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>

</Project>

生成されたcsprojにUnityEngine.dllの参照を付け加える必要がある。 csprojの中身を次のようにする。

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

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <Reference Include="UnityEngine" HintPath="C:\Program Files\Unity\Hub\Editor\2018.2.7f1\Editor\Data\Managed\UnityEngine.dll" />
  </ItemGroup>

</Project>

パスは適宜変更してほしい。

対象とするプラットフォームについても変更しよう。 Unityで.NET Framework 4.6相当の環境で実行するにはcsprojを次のようにする。

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

  <PropertyGroup>
    <TargetFramework>net46</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <Reference Include="UnityEngine" HintPath="C:\Program Files\Unity\Hub\Editor\2018.2.7f1\Editor\Data\Managed\UnityEngine.dll" />
  </ItemGroup>

</Project>

言語のバージョンの設定も行う。

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

  <PropertyGroup>
    <TargetFramework>net46</TargetFramework>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <Reference Include="UnityEngine" HintPath="C:\Program Files\Unity\Hub\Editor\2018.2.7f1\Editor\Data\Managed\UnityEngine.dll" />
  </ItemGroup>

</Project>

UnityEngine.dllが読み込まれていることを確認するために適当なプログラムを書いてみよう。 DllTest.csというファイルを作成し次のように記述する。

using System;
using UnityEngine;

public class DllTest : MonoBehaviour {
  void Update () {
    transform.position = transform.position + Vector3.one;
  }
}

これをビルドしてみる。

dotnet build

生成されたUnityDllTestProject.dllをUnityのAssets/にドラッグ&ドロップする。

dllをドラッグ&ドロップ

するとdllが問題なくUnityに読み込まれるはずだ。 .NET Framework 4.7向けにしたのでUnityのPlayer Settingを変更する必要があるかもしれない。

dllが読み込まれた

作成したDllTestをSphereに追加して実行してみる。

dllのスクリプトをアタッチする

実行結果

問題なく実行できることが確認できた。


手作業でdllを毎回読み込み直してもよいが、 csprojにAssets/内へビルドの成果物をコピーするタスクを追記してみよう。 csprojに次のように追記する。

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

  <PropertyGroup>
    <TargetFramework>net46</TargetFramework>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <Reference Include="UnityEngine" HintPath="C:\Program Files\Unity\Hub\Editor\2018.2.7f1\Editor\Data\Managed\UnityEngine.dll" />
  </ItemGroup>

  <Target Name="PostBuild" AfterTargets="PostBuildEvent">
    <Copy SourceFiles="$(TargetDir)$(TargetName).dll" DestinationFolder="..\Assets\Plugins" />
  </Target>

</Project>

これでコンパイルをするとAssets/Plugins/にdllがコピーされるようになる。


せっかくなので新しいC#の機能を使ってみよう。 DllTest.csに次のように記述する。

using System;
using UnityEngine;

public class DllTest : MonoBehaviour {
  void Start () {
    int f (int n) {
      if (n == 1) return 1;

      return n * f (n - 1);
    }

    Debug.Log (f (5));
  }
}

C# 7のローカル関数を使っている。

実行結果

無事実行できることが確認できた。

UnityでF#を使う

自分でコンパイルすることによりF#を使うこともできるので試してみる。 今回は階乗計算をするライブラリを作る。 適当にプロジェクト用のディレクトリを切る。

<UnityProjectRoot>
├── Assets/
├── Library/
├── ProjectSettings/
├── UnityPackageManager/
├── FSharpProject/
└── <UnityProjectName>.sln

F#のプロジェクトは次のコマンドで生成できる。

dotnet new FSharpProject -lang "F#"

Test.fsを作成し次のようにする。

namespace FSharpProject

module Test =
    let rec factorial n =
        match n with
        | 0 | 1 -> 1
        | _ -> n * factorial (n - 1)

単純な階乗計算を行うプログラムだ。

FSharpProject.fsprojを次のように編集する。

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

  <PropertyGroup>
    <TargetFramework>net46</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <Compile Include="Test.fs" />
  </ItemGroup>

</Project>

次のコマンドでコンパイルを行う。

dotnet build

生成されたdllをAssets/に配置する。

dllの配置

次のC#のプログラムを書いてみる。

using System.Collections;
using System.Collections.Generic;
using FSharpProject;
using UnityEngine;

public class TestScript : MonoBehaviour {

  void Start () {
    Debug.Log (Test.factorial (5));
  }
}

このスクリプトを適当なGameObjectにくっつけて実行してみる。

実行結果

今回は単純な計算処理を作ったが、 上のC#のときと同じようにUnityEngine.dllの参照を加えれば それを利用することもできる。

CompiledName属性を使うことで、メソッドをF#上ではlowerCamelでC#上ではPascalCaseとして扱うこともできるようだ。 C#とF#を組み合わせて使う場合は参考になるだろう。

おわりに

今回はManagedなdllを作成してUnityで読み込んでみた。 アセンブリの分割によるコンパイル時間の短縮や最新バージョンの言語を使うといったことは Unity標準でもできるようになってきている。 そういった現状を踏まえるとこの記事に書いたことも必要なくなっていくのかもしれない。

  • Unity
  • .NET
  • C#
  • F#