20191130のUnityに関する記事は6件です。

【ハンズオン】UniNativeLinqで学ぶGitHub Actions【Mono.Cecil】

この記事はUnity Advent Calendar 2019の第1日目の記事です。

ハンズオンの目標

  • Mono.Cecilの使い方に慣れる
  • LINQの内部実装についての初歩の理解を得る
  • マネージドプラグイン開発に慣れる
  • GitHub Actionsに慣れる
  • UniNativeLinqのファンになる

LINQについては結構資料がありますので、主にMono.CecilとGitHub Actionsについてこの記事で学んでいただければと思います。

筆者の開発環境

  • Windows10 Home
  • Intel Core i7-8750H
  • RAM 16GB
  • Unity
    • 2018.4.9f1
  • .NET Core 3.0
    • 3.0.101
  • Visual Studio 2019
    • 16.4.0 Preview2.0
  • Git
    • 2.24.0.windows.2
  • Rider 2019.3 EAP

前提知識

事前にハンズオンを行う人がインストールしておくべきもの

  • Unity2018.4
    • Unityのバージョンについては2018.4系列である限りなんでもよいです。適宜読み替えを行ってください。
  • .NET Core 3.0
  • Visual Studio2019またはRider2019.2以上
  • Git

第0章 準備

パス通し

以後ターミナル操作はPowerShell上で行います。

まずUnity2018.4の実体のあるパスを追加します。
私は"コントロール パネル\システムとセキュリティ\システム\システムの詳細設定\環境変数\Path"に"C:\Users\conve\Documents\Unity\Editor"と追加していますが、環境変数を汚したくない方は都度下のように書いてパスを通すのが良いのではないでしょうか。

powershell
$Env:Path += ";C:\Program Files\Unity\Hub\Editor\2018.4.13f1\Editor"

作業ディレクトリ

適当なディレクトリの下に新規に作業ディレクトリを作成します。
今回はUniNativeLinqHandsOnという名前にしましょう。

powershell
mkdir UniNativeLinqHandsOn

前節で正常にパスが通っているならば次のシェルコマンドを実行してUnityエディタが起動するはずです。

powershell
unity -createProject ./UniNativeLinqHandsOn/

起動して図のようにエディタが正常に起動しましたか?
image.png

では、一旦エディタを閉じましょう。

Git初期化

GitHub Actionsを使う兼ね合いもあり、Gitのリポジトリを用意しましょう。

powershell
cd UniNativeLinqHandsOn
git init
echo [Ll]ibrary/ [Ll]ogs/ [Oo]bj/ .idea/ .vs/ .vscode/ /*.csproj /*.sln /*.sln.user /TestResults-*.xml > .gitignore

マネージドプラグインの下拵え

これからUniNativeLinqの基礎となるNativeEnumerable<T> where T : unmanagedを実装します。
マネージドプラグインとしてUnity外でDLLをビルドしますので、フォルダを作りましょう。フォルダ名は"core~"とします。

powershell
mkdir core~
cd core~

DLLを作るためにdotnet newコマンドでclasslib(ライブラリ作成)オプションを指定して初期化します。
Class1.csは特に要らないので削除します。
追加で.gitignoreをこのフォルダにも定義します。

powershell
dotnet new classlib
del Class1.cs
echo bin/ obj/ > .gitignore

次にcore~.csprojを編集します。
初期状態では以下のように記述されているはずです。

core~.csproj
<Project Sdk="Microsoft.NET.Sdk">

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

</Project>

csprojの各要素の軽い解説
Unity使いの方はcsprojファイルの中身をこれまで読んだ時に物凄く長くて冗長な記述を見てきたことでしょう。 そもそもcsprojの中身を読まない? アッ、ハイ
Visual Studio 2017の頃からcsprojの新しい形式としてSdk形式というものが登場しました。 参考文献:ufcppのブログ記事
全てを設定していた従来のものよりも、デフォルト値と異なる点のみ設定するSdk形式の方が非常に記述量が少なく可読性が高いですね。
&lt;Project Sdk=&quot;Microsoft.NET.Sdk&quot;&gt;というトップレベルのProject要素にSdk属性が定義されている場合Sdk形式となります。

PropertyGroup要素以下に基本設定を記述します。
TargetFarmework要素にビルド対象のプラットフォーム/フレームワークを指定します。
Unity2018以上で使うことを考え、.NET Standard 2.0を意味するnetstandard2.0を指定しておきます。

RootNamespace要素はVisual Studioで新規にcsファイルを作成する時に使用される名前空間を指定します。


上記csprojを編集して以下の通りにします。

core~.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <RootNamespace>UniNativeLinq</RootNamespace>
    <AllowUnsafeBlocks>True</AllowUnsafeBlocks>
    <AssemblyName>UniNativeLinq</AssemblyName>
    <LangVersion>8</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <Reference Include="UnityEngine.CoreModule">
      <HintPath>○Unityのインストールしてあるフォルダ○\Editor\Data\Managed\UnityEngine\UnityEngine.CoreModule.dll</HintPath>
    </Reference>
  </ItemGroup>
</Project>

AllowUnsafeBlocks要素をTrueにしてポインタを使用可能にします。
AssemblyName要素によりアセンブリ名と出力されたDllファイルの名前を指定します。
そして、LangVersion要素を8に指定してunmanaged型制約の判定を緩めます。 参考文献:アンマネージな総称型に関するunmanaged型制約

最後に、ItemGroup/Reference要素でUnityEngine.CoreModule.dllを参照に追加しましょう。
Unityのランタイムで使用される基本的な機能はUnityEngine.CoreModule.dllを通じて提供されています。

以上で最初の下拵えを終わります。

第1章 UniNativeLinq-Coreの最低限の実装(1)

現在の作業ディレクトリが"UniNativeLinqHandsOn/core~"であることを確認してください。

これから私達は以下のファイル群を作成し、UniNativeLinqのコア機能を最低限の形で実装していきます。

  • NativeEnumerable.cs
    • NativeEnumerable<T>構造体を定義します。
    • NativeArray<T>に対してSpan<T>的な役割を果たす基本的な構造体です。
    • IRefEnumerable<T>を実装します。
  • IRefEnumerable.cs
    • System.Collections.IEnumerable<T>を継承したIRefEnumerable<TEnumerator, T>インターフェイスを定義します。
    • 通常のIEnumerable<T>の型引数が1つであるのに対して、IRefEnumerable<TEnumerator, T>の型引数が2つであるのは、構造体イテレータのボクシングを避ける目的があります。
  • IRefEnumerator.cs
    • System.Collections.IEnumerator<T>を継承したIRefEnumerator<T>インターフェイスを定義します。
    • foreach(ref var item in collection)のような参照をイテレーションするための種々の操作を定義します。
  • AsRefEnumerable.cs
    • NativeEnumerable静的クラスを定義します。
    • NativeEnumerable<T>構造体と名前がほぼ同じですが別物です。
    • NativeArray<T>とT[]に対して拡張メソッドを定義します。
powershell
mkdir Collection
mkdir Interface
mkdir Utility
mkdir API
New-Item Collection/NativeEnumerable.cs
New-Item Interface/IRefEnumerator.cs
New-Item Interface/IRefEnumerable.cs
New-Item API/AsRefEnumerable.cs

NativeEnumerable<T>の最初の定義

最初のNativeEnumerable.cs
NativeEnumerable.cs
namespace UniNativeLinq
{
  public readonly unsafe struct NativeEnumerable<T>
    where T : unmanaged
  {
    public readonly T* Ptr;
    public readonly long Length;

    public NativeEnumerable(T* ptr, long length)
    {
      if (ptr == default || length <= 0)
      {
        Ptr = default;
        Length = default;
        return;
      }
      Ptr = ptr;
      Length = length;
    }

    public ref T this[long index] => ref Ptr[index];
  }
}

unsafeでreadonlyな構造体UniNativeLinq.NativeEnumerable<T>を定義します。
これはジェネリックなTのポインタであるPtrと要素数であるLengthフィールドを露出させています。
nullポインタやダングリングポインタに対する安全性保証は一切ないので、その辺りはエンドユーザーに一切合切投げっぱなしになるC++スタイルです。

これをビルドし、テストコードをUnityの方で実行してみましょう。

最低限のテスト

現在のワーキングディレクトリは"UniNativeLinqHandsOn/core~"のはずです。
以下のようにAssets以下Plugins/UNLフォルダを作成し、core~のビルド成果物であるUniNativeLinq.dllをコピーして配置します。

powershell
mkdir -p ../Assets/Plugins/UNL
dotnet build -c Release
cp -Force ./bin/Release/netstandard2.0/UniNativeLinq.dll ../Assets/Plugins/UNL/UniNativeLinq.dll

ビルドした後毎回"cp -Force ほげほげ"と入力するのも面倒ですので、core~.csprojにビルド後イベントを定義して自動化します。

ビルド後イベントでコピーを自動化したcsproj
core.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <RootNamespace>UniNativeLinq</RootNamespace>
    <AllowUnsafeBlocks>True</AllowUnsafeBlocks>
    <LangVersion>8</LangVersion>
    <AssemblyName>UniNativeLinq</AssemblyName>
  </PropertyGroup>

  <ItemGroup>
    <Reference Include="UnityEngine.CoreModule">
      <HintPath>○Unityのインストールしてあるフォルダ○\Editor\Data\Managed\UnityEngine\UnityEngine.CoreModule.dll</HintPath>
    </Reference>
  </ItemGroup>

  <Target Name="PostBuild" AfterTargets="PostBuildEvent">
    <Exec Command="copy $(TargetPath) $(ProjectDir)..\Assets\Plugins\UNL\UniNativeLinq.dll"/>
  </Target>
</Project>

ビルド後イベントでローカルデプロイの自動化は結構重宝しますのでオススメです。

さて、Assets/Plugins/UNL以下にdllを配置しましたので、それを対象としたテストコードを書きましょう。

powershell
cd ..
unity -projectPath .

エディタが起動しましたね?
ProjectタブのAssetsを選択してコンテキストメニューから"Create/Testing/Tests Assembly Folder"を選択してTestsフォルダーを作成してください。
image.png

無事にTestsフォルダが作成されたならばそのフォルダ以下にTests.asmdefファイルがあるはずです。
それを選択し、Inspectorタブから設定を変更します。
"Allow 'unsafe' Code"と"Override References"にチェックを入れ、"Assembly References"に"UniNativeLinq.dll"を加えてください。
そしてPlatformsをEditorだけにしてください。
次の画像のようなInspectorになるはずです。正しく設定できたならば一番下のApplyボタンを押して設定を保存してください。
image.png

次にProjectタブでAssets/Testsフォルダを右クリックしてコンテキストメニューを呼び出し、"Create/Testing/C# Test Script"を押して新規にテスト用スクリプトを作成します。
ファイル名は"NativeEnumerableTestScript"としましょう。

NativeEnumerableTestScriptをダブルクリックして編集を行います。

NativeEnumerableTestScript.csの中身
NativeEnumerableTestScript.cs
using NUnit.Framework;
using UniNativeLinq;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;

namespace Tests
{
  public sealed unsafe class NativeEnumerableTestScript
  {
    [Test]
    public void DefaultValuePass()
    {
      NativeEnumerable<int> nativeEnumerable = default;
      Assert.AreEqual(0L, nativeEnumerable.Length);
      Assert.IsTrue(nativeEnumerable.Ptr == null);
    }

    [TestCase(0L)]
    [TestCase(-10L)]
    [TestCase(-12241L)]
    [TestCase(long.MinValue)]
    public void ZeroOrNegativeCountTest(long count)
    {
      using (var array = new NativeArray<int>(1, Allocator.Persistent))
      {
        Assert.IsFalse(array.GetUnsafePtr() == null);
        var nativeEnumerable = new NativeEnumerable<int>((int*) array.GetUnsafePtr(), count);
        Assert.AreEqual(0L, nativeEnumerable.Length);
        Assert.IsTrue(nativeEnumerable.Ptr == null);  
      }
    }

    [TestCase(0, Allocator.Temp)]
    [TestCase(1, Allocator.Temp)]
    [TestCase(10, Allocator.Temp)]
    [TestCase(114, Allocator.Temp)]
    [TestCase(0, Allocator.TempJob)]
    [TestCase(1, Allocator.TempJob)]
    [TestCase(10, Allocator.TempJob)]
    [TestCase(114, Allocator.TempJob)]
    [TestCase(0, Allocator.Persistent)]
    [TestCase(1, Allocator.Persistent)]
    [TestCase(10, Allocator.Persistent)]
    [TestCase(114, Allocator.Persistent)]
    public void FromNativeArrayPass(int count, Allocator allocator)
    {
      using (var array = new NativeArray<int>(count, allocator))
      {
        var nativeEnumerable = new NativeEnumerable<int>((int*) array.GetUnsafePtr(), array.Length);
        Assert.AreEqual((long)count, nativeEnumerable.Length);
        for (var i = 0; i < nativeEnumerable.Length; i++)
        {
          Assert.AreEqual(0, nativeEnumerable[i]);
          nativeEnumerable[i] = i;
        }
        for (var i = 0; i < count; i++)
          Assert.AreEqual(i, array[i]);
      }
    }
  }
}


上記コードに従ってUnity Test Runnerの為のEditor Mode Testを複数個用意します。
NUnit.Framework.TestCase属性はバリエーションを作り出すのにかなり便利な属性です。

テストコードの記述後はエディタに戻り、Unity Test Runnerのウィンドウを呼び出しましょう。メニューの"Window/General/Test Runner"をクリックすると開きます。
image.png

出てきたウィンドウのRun Allを押すと全ての項目が緑になり、テスト全てをPassしたことがわかります。
image.png

GitHubにリポジトリを作って成果物を公開する

GitHubに適当なリポジトリ名で新規リポジトリを作成してください。そこにこのプロジェクトを公開します。
私は"HandsOn_CSharpAdventCalendar20191201"と命名しました。
現在のワーキングディレクトリはUniNativeLinqHandsOnのはずです。

powershell
git switch -c develop
git add .
git commit -m "[init]"
git remote add origin https://github.com/pCYSl5EDgo/HandsOn_CSharpAdventCalendar20191201.git
git push -u origin develop

git remote add origin https://github.com/pCYSl5EDgo/HandsOn_CSharpAdventCalendar20191201.gitについては適切な読み替えを行ってください。
適切な.gitignore設定を行っているならば上記の操作で最初のコミットを過不足なくできます。

基本的にローカルのワーキングブランチはdevelopとし、リモートリポジトリのdevelopブランチにpushすることとします。
この措置はリモートのmasterブランチをUPM用にする為のものです。Assetsを含む通常のUnityプロジェクトはUPMの構成と相性が悪いのです。

GitHub Actions対応 CI/CDを行う

これからGitHub ReleasesでUniNativeLinq.dllをpush時に自動的に公開する仕組みを作ります。その際にテストも走らせ、テスト失敗時はリリースしないようにします。

GitHub ActionsでUnityを使うための下拵え 参考文献:GitHub ActionsでUnity開発
GitHub ActionsではLinux, Windows, MacOSXの3種類の環境でCI/CDを行うことができます。
CI/CDサービスからUnityを利用する場合にはLinux環境を利用する形になります。
なぜWindowsやMacではなくLinuxなのかについての補足
WindowsやMacOS環境でもpCYSl5EDgo/setup-unityというGitHub Actionを利用してUnityをインストール可能です。
WinやMacはVMインスタンスとして立ち上がるので、ジョブ毎にMachine IDが変化します。
困ったことに後述するalfファイルの項目にMachine IDがありまして、ここがulfにも受け継がれてしまい、不一致だと認証にコケるのです。
故にulfファイルを使用してオフライン認証を行う手法をWindowsとMacOS環境では取り得ません。

Unityを利用するためには必ずメールアドレスとパスワードで認証する必要があります。
CUIで認証する場合にはオフライン/ 手動アクティベーションを行う方がパスワード漏洩対策として安全です。
これは事前にUnityを動かすPCの情報と、Unityのバージョン、ユーザーのパスワードとメールアドレス等全ての情報を含んだulfファイルを生成しておき、GitHub Actionsでの実行時にulfファイルを使用して認証を行うという手法です。

詳細な手順は公式の参考文献を読んで理解していただくとして、次のような手順でulfファイルを作成してください。

もしあなたがUnity2018.4.12f1以外でこのハンズオンを行う場合
そのバージョンのalfファイルを作成しなくてはいけません。
CreateALFというGitHubのリポジトリをForkし、".github/workflows/CreateLicenseALF.yml"を編集してください。
CreateLicenseALF.yml
name: Create ALF File

on: [push]

jobs:
  build:

    runs-on: ubuntu-latest
    strategy:
      matrix:
        unity-version:
        - 2018.4.9f1
        - 2018.4.10f1
        - 2018.4.11f1
        - 2018.4.12f1
        - 2018.4.13f1
        - 2019.3.0f1
        - 2020.1.0a14

    steps:
    - uses: pCYSl5EDgo/setup-unity@master
      with:
        unity-version: ${{ matrix.unity-version }}
    - name: Create Manual Activation File
      run: /opt/Unity/Editor/Unity -quit -batchmode -nographics -logfile -createManualActivationFile || exit 0
    - name: Create Release
      id: create_release
      uses: actions/create-release@v1.0.0
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      with:
        tag_name: setup-${{ matrix.unity-version }}
        release_name: Release setup-Unity ${{ matrix.unity-version }}
        draft: false
        prerelease: false
    - name: Upload Release Asset
      id: upload-release-asset 
      uses: actions/upload-release-asset@v1.0.1
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      with:
        upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 
        asset_path: Unity_v${{ matrix.unity-version }}.alf
        asset_name: Unity_v${{ matrix.unity-version }}.alf
        asset_content_type: application/xml

matrix.unity-versionにあなたの使うUnityのバージョンを指定してください。
masterブランチにpushするとGitHub Releaseにそのバージョンのalfファイルが登録されます。

入手したulfファイルをリポジトリ"HandsOn_CSharpAdventCalendar20191201"で利用しますが、秘密にすべき情報であるため、GitHub Secretsという機能を使って暗号化しましょう。
GitHub SecretsはSettings/Secretsを選択し、そこにキーと値のペアを登録します。
image.png
今回はulfというキーでulfファイルの中身を登録しましょう。
以上でGitHub ActionsでUnityを扱う下拵えは完了です。

現在のワーキングディレクトリはUniNativeLinqHandsOnのはずです。

powershell
mkdir -p .github/workflows
New-Item .github/workflows/CI.yaml

".github/workflows"フォルダ以下にyamlファイルを作成し、そこに自動化する仕事を記述します。
更にcore~.csproje.txtを新規に作成します。core~.csprojはWindows向けの記述をしていて、そのままではLinuxのDockerコンテナ上では動作しません。

core~.csprojはこのように記述しなおしてください。
ほぼ元のcore~.csprojと変わりは無いですが、HintPathが変更され、かつビルド後イベントが削除されています。
core~.csproj.txt
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <RootNamespace>UniNativeLinq</RootNamespace>
    <AllowUnsafeBlocks>True</AllowUnsafeBlocks>
    <LangVersion>8</LangVersion>
    <AssemblyName>UniNativeLinq</AssemblyName>
  </PropertyGroup>

  <ItemGroup>
    <Reference Include="UnityEngine.CoreModule">
      <HintPath Condition="Exists('C:\Users\conve')">C:\Users\conve\Documents\Unity\Editor\Data\Managed\UnityEngine\UnityEngine.CoreModule.dll</HintPath>
      <HintPath Condition="Exists('/opt/Unity/Editor/Unity')">/opt/Unity/Editor/Data/Managed/UnityEngine/UnityEngine.CoreModule.dll</HintPath>
    </Reference>
  </ItemGroup>
</Project>

CI.yamlの内容
CI.yaml
name: CreateRelease

on:
  push:
    branches:
    - develop

jobs:
  buildReleaseJob:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        unity-version: [2018.4.9f1]
        user-name: [pCYSl5EDgo]
        repository-name: [HandsOn_CSharpAdventCalendar20191201]
        exe: ['/opt/Unity/Editor/Unity']

    steps:
    - uses: pCYSl5EDgo/setup-unity@master
      with:
        unity-version: ${{ matrix.unity-version }}

    - name: License Activation
      run: |
        echo -n "$ULF" > unity.ulf
        ${{ matrix.exe }} -nographics -batchmode -quit -logFile -manualLicenseFile ./unity.ulf || exit 0
      env:
        ULF: ${{ secrets.ulf }}

    - run: git clone https://github.com/${{ github.repository }}

    - uses: actions/setup-dotnet@v1.0.2
      with:
        dotnet-version: '3.0.101'

    - name: Builds DLL
      run: |
        cd ${{ matrix.repository-name }}/core~
        dotnet build -c Release

    - name: Post Process DLL
      run: |
        cd ${{ matrix.repository-name }}
        mv -f ./core~/bin/Release/netstandard2.0/UniNativeLinq.dll ./Assets/Plugins/UNL/UniNativeLinq.dll

    - name: Run Test
      run: ${{ matrix.exe }} -batchmode -nographics -projectPath ${{ matrix.repository-name }} -logFile ./log.log -runEditorTests -editorTestsResultFile ../result.xml || exit 0

    - run: ls -l
    - run: cat log.log
    - run: cat result.xml

    - uses: pCYSl5EDgo/Unity-Test-Runner-Result-XML-interpreter@master
      id: interpret
      with:
        path: result.xml

    - if: steps.interpret.outputs.success != 'true'
      run: exit 1

    - name: Get Version
      run: |
        cd ${{ matrix.repository-name }}
        git describe --tags 1> ../version 2> ../error || exit 0

    - name: Cat Error
      uses: pCYSl5EDgo/cat@master
      id: error
      with:
        path: error

    - if: startsWith(steps.error.outputs.text, 'fatal') != 'true'
      run: |
        cat version
        cat version | awk '{ split($0, versions, "-"); split(versions[1], numbers, "."); numbers[3]=numbers[3]+1; variable=numbers[1]"."numbers[2]"."numbers[3]; print variable; }' > version_increment

    - if: startsWith(steps.error.outputs.text, 'fatal')
      run: echo -n "0.0.1" > version_increment

    - name: Cat
      uses: pCYSl5EDgo/cat@master
      id: version
      with:
        path: version_increment

    - name: Create Release
      id: create_release
      uses: actions/create-release@v1.0.0
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      with:
        tag_name: ${{ steps.version.outputs.text }}
        release_name: Release Unity${{ matrix.unity-version }} - v${{ steps.version.outputs.text }}
        draft: false
        prerelease: false

    - name: Upload DLL
      uses: actions/upload-release-asset@v1.0.1
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      with:
        upload_url: ${{ steps.create_release.outputs.upload_url }}
        asset_path: ${{ matrix.repository-name }}/Assets/Plugins/UNL/UniNativeLinq.dll
        asset_name: UniNativeLinq.dll
        asset_content_type: application/vnd.microsoft.portable-executable

jobs.buildReleaseJob.startegy.matrix以下の3項目は適切に書き換えてください。

unity-tag: [2018.4.12f1]
user-name: [pCYSl5EDgo]
repository-name: [HandsOn_CSharpAdventCalendar20191201]
  • unity-tag
    • Unityのバージョン
  • user-name
    • あなたのGitHubアカウント名
  • repository-name
    • あなたのリポジトリ名

全体の流れとしては以下の通りになります。

  • 作業リポジトリをクローン
    • actions/checkoutだとcore~などの隠しフォルダを無視してしまうのでgit cloneするのが安牌
  • GitHub Secretsに登録したulfファイルをトップレベルに echo -n "${ULF}" > Unity_v2018.x.ulfで出力
    • 環境変数に仕込んでいるので外部にバレずに利用可能
  • ビルドに必要なdotnet core 3.0環境のセットアップ
  • DLLをビルドしてそれをAssets/Plugins/UNL以下に配置
  • Unityのライセンス認証
  • Unity Test Runnerをコマンドラインから走らせる
  • テストに失敗したなら全体を失敗させて終了
  • 前回のビルド時のバージョンを取得
  • 取得に失敗したならば今回のバージョンを0.0.1とする
  • 取得成功時はawkでゴニョゴニョしてマイナーバージョンをインクリメントする
  • GitHub Releasesに新規リリースを作成する
  • リリースにファイルを追加する
powershell
git add .
git commit -m "[update]Publish Release"
git push

現在のワーキングディレクトリはUniNativeLinqHandsOnのはずです。
全ての作業が終わったらGitHubにpushして最初のGitHub Releasesを公開しましょう。

IEnumerable<T>の実装

NativeEnumerable<T>の中身として全てのフィールドとインデクサを定義しました。
これからIEnumerable<T>を実装します。記述が増えるのでpartial structにします。

IEnumerable<T>を実装したNativeEnumerable<T>
NativeEnumerable.cs
using System.Collections;
using System.Collections.Generic;

namespace UniNativeLinq
{
  public readonly unsafe partial struct NativeEnumerable<T>
    : IEnumerable<T>
    where T : unmanaged
  {
    public readonly T* Ptr;
    public readonly long Length;

    public NativeEnumerable(T* ptr, long length)
    {
      if (ptr == default || length <= 0)
      {
        Ptr = default;
        Length = default;
        return;
      }
      Ptr = ptr;
      Length = length;
    }

    public ref T this[long index] => ref Ptr[index];

    public Enumerator GetEnumerator() => new Enumerator(this);
    IEnumerator<T> IEnumerable<T>.GetEnumerator() => GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
  }
}


IEnumerator<T>を実装したNativeEnumerable<T>.Enumerator
NativeEnumerable.Enumerator.cs
using System.Collections;
using System.Collections.Generic;

namespace UniNativeLinq
{
  public readonly partial struct NativeEnumerable<T>
  {
    public unsafe struct Enumerator : IEnumerator<T>
    {
      private readonly T* ptr;
      private readonly long length;
      private long index;

      public Enumerator(NativeEnumerable<T> parent)
      {
        ptr = parent.Ptr;
        length = parent.Length;
        index = -1;
      }

      public bool MoveNext() => ++index < length;
      public void Reset() => index = -1;
      public ref T Current => ref ptr[index];
      T IEnumerator<T>.Current => Current;
      object IEnumerator.Current => Current;
      public void Dispose() => this = default;
    }
  }
}

イテレータ構造体を内部型として定義するのはforeachの性能向上の常套手段です。

powershell
cd core~
dotnet build -c Release
cd ..
unity -projectPath .

ビルドをした後エディタを起動し、テストコードを書きましょう。
NativeEnumerableTestScriptクラスに追記する形で単一のクラスを肥大させましょう。
Unityのよくわからない仕様なのですが、1つのプロジェクトに2ファイル以上のテストスクリプトが存在するとコマンドラインからrunEditorTestsするとエラー吐きます。
このような事情もあり、簡易的な処置ですが神テストクラスを肥えさせます。本格的な処置についてはいずれまた別の記事で書くこともあるかも知れません。

NativeEnumerableTestScript.cs
NativeEnumerableTestScript.cs
[TestCase(0, Allocator.Temp)]
[TestCase(114, Allocator.Temp)]
[TestCase(114514, Allocator.Temp)]
[TestCase(0, Allocator.TempJob)]
[TestCase(114, Allocator.TempJob)]
[TestCase(114514, Allocator.TempJob)]
[TestCase(0, Allocator.Persistent)]
[TestCase(114, Allocator.Persistent)]
[TestCase(114514, Allocator.Persistent)]
public void IEnumerableTest(int count, Allocator allocator)
{
    using (var array = new NativeArray<long>(count, allocator))
    {
    var nativeEnumerable = new NativeEnumerable<long>((long*) array.GetUnsafePtr(), array.Length);
    Assert.AreEqual(count, nativeEnumerable.Length);
    for (var i = 0L; i < count; i++)
        nativeEnumerable[i] = i;
    var index = 0L;
    foreach (ref var i in nativeEnumerable)
    {
        Assert.AreEqual(index++, i);
        i = index;
    }
    index = 1L;
    foreach (var i in nativeEnumerable)
        Assert.AreEqual(index++, i);
    }
}

foreach文が正しく動いていることがこれで確認できます。
Unityエディターを閉じた後、GitHubにpushしてCI/CDを体感しましょう。

powershell
git add .
git commit -m "[update]Implement IEnumerable<T> & IEnumerator<T>"
git push

AsEnumerable()に相当するAsRefEnumerable()の実装

NativeArray<T>からNativeEnumerable<T>を生成するのに一々 var nativeEnumerable = new NativeEnumerable<T>((T*) array.GetUnsafePtr(), array.Length);と記述するのも手間です。
var nativeEnumerable = array.AsRefEnumerable();だったら非常に楽ですので、拡張メソッドを定義します。

AsRefEnumerable.cs
namespace UniNativeLinq
{
  public static unsafe class NativeEnumerable
  {
    public static NativeEnumerable<T> AsRefEnumerable<T>(this Unity.Collections.NativeArray<T> array)
      where T : unmanaged
      => new NativeEnumerable<T>(ptr: (T*)Unity.Collections.LowLevel.Unsafe.NativeArrayUnsafeUtility.GetUnsafeBufferPointerWithoutChecks(array), length: array.Length);
  }
}

IRefEnumerable/torの定義と実装

NativeEnumerable<T>とその内部型Enumeratorは public Enumerator GetEnumetor();public ref T Current{get;}が特徴的な要素です。
これをインターフェイスに抽出します。

IRefEnumerable.csとIRefEnumerator.csの定義
IRefEnumerable.cs
namespace UniNativeLinq
{
  public interface IRefEnumerable<TEnumerator, T> : System.Collections.Generic.IEnumerable<T>
    where TEnumerator : IRefEnumerator<T>
  {
    new TEnumerator GetEnumerator();
  }
}
IRefEnumerator.cs
namespace UniNativeLinq
{
  public interface IRefEnumerator<T> : System.Collections.Generic.IEnumerator<T>
  {
    new ref T Current { get; }
  }
}

上記インターフェイスをNativeEnumerableに実装します。
実際は各ファイルを一行書き換えるだけです。
NativeEenumerable.cs
public readonly unsafe partial struct NativeEnumerable<T>
  : IRefEnumerable<NativeEnumerable<T>.Enumerator, T>
NativeEenumerable.Enumerator.cs
public unsafe struct Enumerator : IRefEnumerator<T>

テストコードには何も差は生じません。(既存の実装を元にインターフェイスを抽出しただけですので)

第2章 初めてのAPI - Select

LINQで一番使うAPIはSelectまたはWhereのはずです。
今回はUniNativeLinqの特異性を学ぶのに好適であるため、Selectを実装してみます。

通常LINQのSelectについて

通常のSystem.Linq.Enumerableの提供するSelectメソッドのシグネチャを見てみましょう。

Select.cs
public static IEnumerable<TTo> Select<TFrom, TTo>(this IEunmerable<TFrom> collection, Func<TFrom, TTo> func);

引数にIEnumerable<TFrom>なコレクションと、Func<TFrom, TTo>な写像を取ってマッピングを行います。
LINQの優れている点は拡張メソッドの型引数を(C#の貧弱な型推論でも)型推論完了できるという点にあります。

標準にLINQに習ってAPIを定義してみましょう。

UniNativeLinq.Select.cs
public static IRefEnumerable<TToEnumerator, TTo> Select<TFromEnumerator, TFrom, TToEnumerator, TTo>(this IRefEunmerable<TFromEnumerator, TFrom> collection, Func<TFrom, TTo> func);

このような感じでしょうか?
TToEnumeratorを引数から導出できず、センスが悪いですね。

実際のUniNativeLinqでは新たにSelectEnumerable<TPrevEnumerable, TPrevEnumerator, TPrev, T, TAction>型を定義します。
powershell
New-Item API/RefAction.cs
New-Item Interface/IRefAction.cs
New-Item Utility/DelegateRefActionToStructOperatorAction.cs
New-Item Utility/Unsafe.cs
New-Item Collection/SelectEnumerable.cs
New-Item Collection/SelectEnumerable.Enumerator.cs

細々と必要な型があるので他にもいくつか新規にファイルを作成します。

RefAction.csとIRefAction.cs
似たような内容なので同一ファイル内に記述するのも良いでしょう。
RefAction.cs
namespace UniNativeLinq
{
  public delegate void RefAction<T0, T1>(ref T0 arg0, ref T1 arg1);
  public interface IRefAction<T0, T1>
  {
    void Execute(ref T0 arg0, ref T1 arg1);
  }
}
namespace UniNativeLinq
{
  public readonly struct DelegateRefActionToStructOperatorAction<T0, T1> : IRefAction<T0, T1>
  {
    private readonly RefAction<T0, T1> action;
    public DelegateRefActionToStructOperatorAction(RefAction<T0, T1> action) => this.action = action;
    public void Execute(ref T0 arg0, ref T1 arg1) => action(ref arg0, ref arg1);
  }
}

Unsafe.csは、System.Runtime.CompilerServices.Unsafeの一部抜粋です。
Unsafe.cs
namespace UniNativeLinq
{
  public static class Unsafe
  {
    // ref T AsRef<T>(in T value) => ref value;
    public static ref T AsRef<T>(in T value) => throw new System.NotImplementedException();
  }
}

実際の所、NotImplementExceptionとして中身は空っぽなモックAPIです。
後にいい感じにこのモックAPIを処理します。

Unsafe.AsRefはin引数をref戻り値に変換します。
引数にreadonlyフィールドの参照を与えたら、その戻り値が変更可能な参照になります。

SelectEnumerable.cs
SelectEnumerable.cs
namespace UniNativeLinq
{
  public readonly partial struct SelectEnumerable<TPrevEnumerable, TPrevEnumerator, TPrev, T, TAction>
    : IRefEnumerable<SelectEnumerable<TPrevEnumerable, TPrevEnumerator, TPrev, T, TAction>.Enumerator, T>
    where TPrevEnumerable : IRefEnumerable<TPrevEnumerator, TPrev>
    where TPrevEnumerator : IRefEnumerator<TPrev>
    where TAction : IRefAction<TPrev, T>
  {
    private readonly TPrevEnumerable enumerable;
    private readonly TAction action;

    public SelectEnumerable(in TPrevEnumerable enumerable)
    {
      this.enumerable = enumerable;
      action = default;
    }
    public SelectEnumerable(in TPrevEnumerable enumerable, in TAction action)
    {
      this.enumerable = enumerable;
      this.action = action;
    }

    public Enumerator GetEnumerator() => new Enumerator(ref Unsafe.AsRef(in enumerable), action);
    System.Collections.Generic.IEnumerator<T> System.Collections.Generic.IEnumerable<T>.GetEnumerator() => GetEnumerator();
    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
  }
}


GetEnumerator()においてUnsafe.AsRef(in enumerable)と記述されています。
Unsafe.AsRefはreadonly制約を無視する危険なメソッドですが、この場合において問題はありません。
UniNativeLinqの提供する範囲において、全てのGetEnumeratorメソッドがreadonlyなメソッドであるからです。
この辺りをC#の型制約で保証できればよいのですが、出来ないため今回のようにUnsafe.AsRefを利用する必要があるのです。

SelectEnumerable.Enumerator.cs
SelectEnumerable.Enumerator.cs
namespace UniNativeLinq
{
  public readonly partial struct SelectEnumerable<TPrevEnumerable, TPrevEnumerator, TPrev, T, TAction>
  {
    public struct Enumerator : IRefEnumerator<T>
    {
      private TPrevEnumerator enumerator;
      private TAction action;
      private T element;

      public Enumerator(ref TPrevEnumerable enumerable, in TAction action)
      {
        enumerator = enumerable.GetEnumerator();
        this.action = action;
        element = default;
      }

      public bool MoveNext()
      {
        if (!enumerator.MoveNext()) return false;
        action.Execute(ref enumerator.Current, ref element);
        return true;
      }

      public void Reset() => throw new System.InvalidOperationException();
      public ref T Current => throw new System.NotImplementedException();
      T System.Collections.Generic.IEnumerator<T>.Current => Current;
      object System.Collections.IEnumerator.Current => Current;
      public void Dispose() { }
    }
  }
}


SelectEnumerableの実装はそこまで変なものではありません。
コンストラクタで必要な情報をフィールドに初期化し、GetEnumerator()でEnumeratorを返すだけのシンプルな作りです。
EnumeratorではIRefAction<T0, T1>を実装した型引数TActionのインスタンスactionを使用してMoveNext()する度にT型フィールドであるelementを更新しています。

このEnumeratorの最大の特徴は、 public ref T Current => throw new System.NotImplementedException();です。
そう、未実装のままなのです。これはバグではなく極めて意図的な仕様です。
これをこのままビルドしてテストコードを追加してもエラーを吐くだけです。

本当は public ref T Current => ref element;と記述したいのですが、C#の文法の制限として無理です

UniNativeLinq.dllのポストプロセス用dotnet core 3.0プロジェクト

現在のワーキングディレクトリはUniNativeLinqHandsOnのはずです。
Mono.Cecilを利用してUniNativeLinq.dllを編集してSelectEnumerable.Enumerator.CurrentからNotImplementedExceptionを消し飛ばしましょう。

powershell
mkdir post~
cd post~
dotnet new console
echo bin/ obj/ post~.sln > .gitignore
dotnet add package Mono.Cecil
New-Item DllProcessor.cs
New-Item InstructionUtility.cs
New-Item ToDefinitionUtility.cs
New-Item GenericInstanceUtility.cs

参考までにpost~.csprojの中身
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <RootNamespace>_post</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Mono.Cecil" Version="0.11.1" />
  </ItemGroup>
</Project>

PackageReferenceタグでMono.Cecilをインストール可能です。

Program.cs
Program.cs
using System;
using System.IO;
public sealed class Program
{
  static int Main(string[] args)
  {
    if (!ValidateArguments(args, out FileInfo inputUniNativeLinqDll, out FileInfo outputUniNativeLinqDllPath, out DirectoryInfo unityEngineFolder))
    {
      return 1;
    }
    using (DllProcessor processor = new DllProcessor(inputUniNativeLinqDll, outputUniNativeLinqDllPath, unityEngineFolder))
    {
      processor.Process();
    }
    return 0;
  }

  private static bool ValidateArguments(string[] args, out FileInfo inputUniNativeLinqDll, out FileInfo outputNativeLinqDllPath, out DirectoryInfo unityEngineFolder)
  {
    if (args.Length != 3)
    {
      Console.Error.WriteLine("Invalid argument count.");
      inputUniNativeLinqDll = default;
      outputNativeLinqDllPath = default;
      unityEngineFolder = default;
      return false;
    }
    inputUniNativeLinqDll = new FileInfo(args[0]);
    if (!inputUniNativeLinqDll.Exists)
    {
      Console.Error.WriteLine("Empty Input UniNativeLinq.dll path");
      outputNativeLinqDllPath = default;
      unityEngineFolder = default;
      return false;
    }
    string outputNativeLinqDllPathString = args[1];
    if (string.IsNullOrWhiteSpace(outputNativeLinqDllPathString))
    {
      Console.Error.WriteLine("Empty Output UniNativeLinq.dll path");
      unityEngineFolder = default;
      outputNativeLinqDllPath = default;
      return false;
    }
    outputNativeLinqDllPath = new FileInfo(outputNativeLinqDllPathString);
    unityEngineFolder = new DirectoryInfo(args[2]);
    if (!unityEngineFolder.Exists)
    {
      Console.Error.WriteLine("Unity Engine Dll Folder does not exist");
      return false;
    }
    return true;
  }
}

Main関数はコマンドライン引数を2つ要求します。

  • 第1引数:core~で生成したDllのパス "core~/bin/Release/netstandard2.0/UniNativeLinq.dll"
  • 第2引数:Mono.Cecilで編集した後、Dllを出力するパス "Assets/Plugins/UNL/UniNativeLinq.dll"

ValidateArgumentsで引数の妥当性を検証します。
IDisposableを実装したDllProcessorのインスタンスを生成し、Processメソッドを実行することで適切な処理を加えます。


DllProcessor.cs
DllProcessor.cs
using System;
using System.IO;
using System.Linq;
using Mono.Cecil;
using Mono.Cecil.Cil;
internal struct DllProcessor : IDisposable
{
  private readonly ModuleDefinition mainModule;
  private readonly FileInfo outputDll;

  public DllProcessor(FileInfo input, FileInfo output)
  {
    mainModule = ModuleDefinition.ReadModule(input.FullName);
    outputDll = output;
  }

  public void Process()
  {
    ProcessEachMethod(RewriteUnsafeAsRef);
    mainModule.Types.Remove(mainModule.GetType("UniNativeLinq", "Unsafe"));
    ProcessEachMethod(RewriteThrowNotImplementedException, PredicateThrowNotImplementedException);
  }

  private void ProcessEachMethod(Action<MethodDefinition> action, Func<TypeDefinition, bool> predicate = default)
  {
    foreach (TypeDefinition typeDefinition in mainModule.Types)
      ProcessEachMethod(action, predicate, typeDefinition);
  }

  private void ProcessEachMethod(Action<MethodDefinition> action, Func<TypeDefinition, bool> predicate, TypeDefinition typeDefinition)
  {
    foreach (TypeDefinition nestedTypeDefinition in typeDefinition.NestedTypes)
      ProcessEachMethod(action, predicate, nestedTypeDefinition);
    if (predicate is null || predicate(typeDefinition))
      foreach (MethodDefinition methodDefinition in typeDefinition.Methods)
        action(methodDefinition);
  }

  private void RewriteUnsafeAsRef(MethodDefinition methodDefinition)
  {
    Mono.Collections.Generic.Collection<Instruction> instructions;
    try
    {
      instructions = methodDefinition.Body.Instructions;
    }
    catch (NullReferenceException)
    {
      return;
    }
    catch
    {
      Console.WriteLine(methodDefinition.FullName);
      throw;
    }
    for (int i = instructions.Count - 1; i >= 0; i--)
    {
      Instruction instruction = instructions[i];
      if (instruction.OpCode.Code != Code.Call) continue;
      MethodDefinition callMethodDefinition;
      try
      {
        callMethodDefinition = ((MethodReference)instruction.Operand).ToDefinition();
      }
      catch
      {
        continue;
      }
      if (callMethodDefinition.Name != "AsRef" || callMethodDefinition.DeclaringType.Name != "Unsafe") continue;
      instructions.RemoveAt(i);
    }
  }

  private bool PredicateThrowNotImplementedException(TypeDefinition typeDefinition)
  {
    if (!typeDefinition.HasFields) return false;
    return typeDefinition.Fields.Any(field => !field.IsStatic && field.Name == "element");
  }

  private void RewriteThrowNotImplementedException(MethodDefinition methodDefinition)
  {
    if (methodDefinition.IsStatic) return;
    FieldReference elementFieldReference = methodDefinition.DeclaringType.FindField("element").MakeHostInstanceGeneric(methodDefinition.DeclaringType.GenericParameters);
    ILProcessor processor = methodDefinition.Body.GetILProcessor();
    Mono.Collections.Generic.Collection<Instruction> instructions = methodDefinition.Body.Instructions;
    for (int i = instructions.Count - 1; i >= 0; i--)
    {
      Instruction throwInstruction = instructions[i];
      if (throwInstruction.OpCode.Code != Code.Throw) continue;
      Instruction newObjInstruction = instructions[i - 1];
      if (newObjInstruction.OpCode.Code != Code.Newobj) continue;
      MethodDefinition newObjMethodDefinition;
      try
      {
        newObjMethodDefinition = ((MethodReference)newObjInstruction.Operand).ToDefinition();
      }
      catch
      {
        continue;
      }
      if (newObjMethodDefinition.Name != ".ctor" || newObjMethodDefinition.DeclaringType.FullName != "System.NotImplementedException") continue;
      newObjInstruction.Replace(Instruction.Create(OpCodes.Ldarg_0));
      throwInstruction.Replace(Instruction.Create(OpCodes.Ldflda, elementFieldReference));
      processor.InsertAfter(throwInstruction, Instruction.Create(OpCodes.Ret));
    }
  }

  public void Dispose()
  {
    using (Stream writer = new FileStream(outputDll.FullName, FileMode.Create, FileAccess.Write))
    {
      mainModule.Assembly.Write(writer);
    }
    mainModule.Dispose();
  }
}
  • Process
    • 元となる不完全なDllに対して処理を加えて完全なDllにするメソッドです。
  • ProcessEachMethod
    • Dllに含まれる全ての型の全てのメソッドを走査して全てのメソッドに対して引数のactionを適用します。
  • RewriteUnsafeAsRef
    • Unsafe.AsRefはreadonlyな要素を非readonlyな状態に変換する極めて危険なAPIです。
    • 使い所がGetEnumeratorに限定されているため特に問題はないです。
  • PredicateThrowNotImplementedException
    • インスタンスフィールドに"element"という名前のそれが存在する型のみを処理対象に選ぶメソッドです。
  • RewriteThrowNotImplementedException
    • throw new NotImplementedException();return ref this.element;に置換します。
InstructionUtility.cs
InstructionUtility.cs
using Mono.Cecil.Cil;
internal static class InstructionUtility
{
  public static void Replace(this Instruction instruction, Instruction replace) => (instruction.OpCode, instruction.Operand) = (replace.OpCode, replace.Operand);
}

RewriteThrowNotImplementedException内部で使用されます。
ILの命令を置換するための拡張メソッドです。
ILProcessorのReplaceメソッドはバグを誘発するわりと使い物にならないメソッドです。
gotoやif, switchなどのジャンプ系の命令の行き先にまつわる致命的なバグを生じます。
こうしてわざわざ拡張メソッドを用意する必要があるのです。


ToDefinitionUtility.cs
ToDefinitionUtility.cs
using Mono.Cecil;
internal static class ToDefinitionUtility
{
  public static TypeDefinition ToDefinition(this TypeReference reference) => reference switch
  {
    TypeDefinition definition => definition,
    GenericInstanceType generic => generic.ElementType.ToDefinition(),
    _ => reference.Resolve(),
  };
  public static MethodDefinition ToDefinition(this MethodReference reference) => reference switch
  {
    MethodDefinition definition => definition,
    GenericInstanceMethod generic => generic.ElementMethod.ToDefinition(),
    _ => reference.Resolve(),
  };
}

特に気にする必要はない拡張メソッドです。
Resolve()が例外を投げる可能性が結構あります。

GenericInstanceUtility.cs
GenericInstanceUtility.cs
using Mono.Cecil;
using System.Linq;
using System.Collections.Generic;
internal static class GenericInstanceUtility
{
  public static FieldReference FindField(this TypeReference type, string name)
  {
    if (type is TypeDefinition definition)
      return definition.FindField(name);
    if (type is GenericInstanceType genericInstanceType)
      return genericInstanceType.FindField(name);
    var typeDefinition = type.ToDefinition();
    var fieldDefinition = typeDefinition.Fields.Single(x => x.Name == name);
    if (fieldDefinition.Module == type.Module)
      return fieldDefinition;
    return type.Module.ImportReference(fieldDefinition);
  }
  public static FieldReference FindField(this TypeDefinition type, string name) => type.Fields.Single(x => x.Name == name);

  public static FieldReference FindField(this GenericInstanceType type, string name)
  {
    var typeDefinition = type.ToDefinition();
    var definition = typeDefinition.Fields.Single(x => x.Name == name);
    return definition.MakeHostInstanceGeneric(type.GenericArguments);
  }

  public static FieldReference MakeHostInstanceGeneric(this FieldReference self, IEnumerable<TypeReference> arguments) => new FieldReference(self.Name, self.FieldType, self.DeclaringType.MakeGenericInstanceType(arguments));

  public static GenericInstanceType MakeGenericInstanceType(this TypeReference self, IEnumerable<TypeReference> arguments)
  {
    var instance = new GenericInstanceType(self);
    foreach (var argument in arguments)
      instance.GenericArguments.Add(argument);
    return instance;
  }
}

CI.yamlをアップデート

post~によりUniNativeLinq.dllにポストプロセスをする必要があり、CI.yamlを書き換えます。

CI.yaml全文
CI.yaml
name: CreateRelease

on:
  push:
    branches:
    - develop

jobs:
  buildReleaseJob:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        unity-version: [2018.4.9f1]
        user-name: [pCYSl5EDgo]
        repository-name: [HandsOn_CSharpAdventCalendar20191201]
        exe: ['/opt/Unity/Editor/Unity']

    steps:
    - uses: pCYSl5EDgo/setup-unity@master
      with:
        unity-version: ${{ matrix.unity-version }}

    - name: License Activation
      run: |
        echo -n "$ULF" > unity.ulf
        ${{ matrix.exe }} -nographics -batchmode -quit -logFile -manualLicenseFile ./unity.ulf || exit 0
      env:
        ULF: ${{ secrets.ulf }}

    - run: git clone https://github.com/${{ github.repository }}

    - uses: actions/setup-dotnet@v1.0.2
      with:
        dotnet-version: '3.0.101'

    - name: Builds DLL
      run: |
        cd ${{ matrix.repository-name }}/core~
        dotnet build -c Release

    - name: Post Process DLL
      run: |
        cd ${{ matrix.repository-name }}/post~
        ls -l ../Assets/Plugins/UNL/
        dotnet run ../core~/bin/Release/netstandard2.0/UniNativeLinq.dll ../Assets/Plugins/UNL/UniNativeLinq.dll
        ls -l ../Assets/Plugins/UNL/

    - name: Run Test
      run: ${{ matrix.exe }} -batchmode -nographics -projectPath ${{ matrix.repository-name }} -logFile ./log.log -runEditorTests -editorTestsResultFile ../result.xml || exit 0

    - run: ls -l
    - run: cat log.log
    - run: cat result.xml

    - uses: pCYSl5EDgo/Unity-Test-Runner-Result-XML-interpreter@master
      id: interpret
      with:
        path: result.xml

    - if: steps.interpret.outputs.success != 'true'
      run: exit 1

    - name: Get Version
      run: |
        cd ${{ matrix.repository-name }}
        git describe --tags 1> ../version 2> ../error || exit 0

    - name: Cat Error
      uses: pCYSl5EDgo/cat@master
      id: error
      with:
        path: error

    - if: startsWith(steps.error.outputs.text, 'fatal') != 'true'
      run: |
        cat version
        cat version | awk '{ split($0, versions, "-"); split(versions[1], numbers, "."); numbers[3]=numbers[3]+1; variable=numbers[1]"."numbers[2]"."numbers[3]; print variable; }' > version_increment

    - if: startsWith(steps.error.outputs.text, 'fatal')
      run: echo -n "0.0.1" > version_increment

    - name: Cat
      uses: pCYSl5EDgo/cat@master
      id: version
      with:
        path: version_increment

    - name: Create Release
      id: create_release
      uses: actions/create-release@v1.0.0
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      with:
        tag_name: ${{ steps.version.outputs.text }}
        release_name: Release Unity${{ matrix.unity-tag }} - v${{ steps.version.outputs.text }}
        draft: false
        prerelease: false

    - name: Upload DLL
      uses: actions/upload-release-asset@v1.0.1
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      with:
        upload_url: ${{ steps.create_release.outputs.upload_url }}
        asset_path: ${{ matrix.repository-name }}/core~/bin/Release/netstandard2.0/UniNativeLinq.dll
        asset_name: UniNativeLinq.dll
        asset_content_type: application/vnd.microsoft.portable-executable


変更箇所のみ抜粋
CI.yaml
- name: Builds DLL
  run: |
    mkdir artifact
    cd ${{ matrix.repository-name }}/core~
    dotnet build -c Release

- name: Post Process DLL
  run: |
    cd ${{ matrix.repository-name }}/post~
    dotnet run ../core~/bin/Release/netstandard2.0/UniNativeLinq.dll ../Assets/Plugins/UNL/UniNativeLinq.dll

- name: License Activation


powershell
git add .
git commit -m "[add]post~ prot-process project"
git push

第3章 補足

UnityPackage化

ライブラリをGitHubから提供するならば素のDLLを提供するだけというのも不親切です。
やはりunitypackageファイルをGitHub Releasesから提供したいものです。

この節ではコマンドラインからUnityを操作してunitypackageを作成します。
Unityのコマンドラインの機能自体ではunitypackageを作成することは不可能ですが、プロジェクトのEditorフォルダ直下に存在するクラスのstaticメソッドを呼び出すことが可能です。
UnityEditor.AssetDatabase.ExportPackageメソッドをstaticメソッド内で呼び出してunitypackageを作成します。

現在のワーキングディレクトリはUniNativeLinqHandsOnのはずです。

powershell
mkdir -p Assets/Editor
New-Item Assets/Editor/UnityPackageBuilder.cs

UnityPackageBuilder.cs
UnityPackageBuilder.cs
using System;
using UnityEditor;
namespace HandsOn
{
  public static class UnityPackageBuilder
  {
    public static void Build()
    {
      string[] args = Environment.GetCommandLineArgs();
      string exportPath = args[args.Length - 1];
      AssetDatabase.ExportPackage(
      new[]{
        "Assets/Plugins/UNL/UniNativeLinq.dll"
      },
      exportPath,
      ExportPackageOptions.Default
      );
    }
  }
}

コマンドラインから呼び出すメソッドのシグネチャは必ずSystem.Actionである必要があります。
コマンドライン引数を扱いたい場合にはSystem.Environment.GetCommandLineArgsメソッドから適切に文節処理された文字列の配列を受け取りましょう。

これは今回限りの約束事ですが、最後のコマンドライン引数がunitypackageの出力先のパスを示すようにします。

AssetDatabase.ExportPackageの第一引数にstring[]を渡してunitypackageを構築します。
ここで渡すファイルのパスはプロジェクトのルートに対する相対パスですね。

HandsOn.UnityPackageBuilder.BuildをGitHub Actionsから呼び出し、リリースに同梱します。

yamlファイルの最後に追記する部分(インデントには気を付けてください)
CI.yamlへの追記
- name: Create UnityPackage
  run: ${{ matrix.exe }} -batchmode -nographics -quit -projectPath ${{ matrix.repository-name }} -logFile ./log.log -executeMethod HandsOn.UnityPackageBuilder.Build "../UniNativeLinq.unitypackage"
- run: cat log.log
- name: Upload Unity Package
  uses: actions/upload-release-asset@v1.0.1
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  with:
    upload_url: ${{ steps.create_release.outputs.upload_url }}
    asset_path: UniNativeLinq.unitypackage
    asset_name: UniNativeLinq.unitypackage
    asset_content_type: application/x-gzip

終わりに

UniNativeLinq本家ではエディタ拡張に関連して更にえげつない最適化やMono.Cecilテクニックが使用されています。
既存のLINQに比べて非常に高速に動作しますので是非使ってください。

このハンズオンよりも更に深くMono.CecilやUniNativeLinqを学びたいという方は私のTwitterのDMなどでご相談いただければ嬉しいです。


  1. 画像はUnityのサイトより引用 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UnityのGameView上に好きな画像載せれる拡張を作った

こんちにわ!
今回、ちょっとしたUnityの拡張機能を作ったのでご紹介します。

作ったもの

UnityのGameWindowに好きな画像を重ねられる機能です。
sample.gif

上記の画像のようにドラッグ・アンド・ドロップで
GameViewの表示にぴったり合うようにして
手元の好きなイメージが重ねられて
透明度を変えてそれぞれの画像を見ることができます。
予めGameViewのサイズを画像のアスペクト比と合わせておく必要があります

なんで作ったの?

開発をする際にUIの仮配置を行うときに当たりになる物が欲しく
今までは、
1. UIデザイン画像をインポートする
2. 新しくImageオブジェクトを作る
3. サイズや位置、表示順など調整する
4. UI画像アタッチする
5. アルファ変えながら画像見て調整する
6. 他のアス比にしても大丈夫か3-4を繰り返す
7. 不要になった画像とGameObject削除
8. おわり!
とやっていたのですがこの方法では手数が多く
仮画像をうっかり入れそうになったり不要なGameObject残したり
してしまい不便でした。

そのため今回の機能を作ってみました。
これにより
1. Windowを開く
2. 画像をドラッグ・アンド・ドロップする
3. アルファ変えながら画像見て調整する
4. 他のアス比にしても大丈夫か2-3を繰り返す
5. おわり!
な感じでそんなに手間を掛けずプロジェクトを汚さず
確認できるようになりました。

使い方

  1. GameViewを表示します。表示してないと動かないです。理由は後述
  2. ImageOverlapWindowを開きます。
  3. Windowがでるので自分の好きな画像をドラッグ・アンド・ドロップでぺっちょんします。(大人の事情でPNGしか対応できてないです
  4. 下にスクロールすると怪しいスライダーが出るのでそれを動かすと透明度変わります。
  5. GameViewと同じサイズで追従するのでチェックボックスで切り替えられます。
  6. おわり!

*予めGameViewのサイズを画像のアスペクト比と合わせておく必要があります。

内部の実装について

    RenderTexture GetGameTexture()
    {
        var gameMainView = GetMainGameView();
        if (gameMainView == null)
        {
            return null;
        }

        var t = System.Type.GetType("UnityEditor.GameView,UnityEditor");
        if (t == null) return null;
        var renderTexture = t.GetField("m_TargetTexture",
            BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.GetField | BindingFlags.FlattenHierarchy |
            BindingFlags.SetField);
        if (renderTexture == null)
        {
            return null;
        }

        return (RenderTexture) renderTexture.GetValue(gameMainView);

    }

    public static EditorWindow GetMainGameView()
    {
        var t = System.Type.GetType("UnityEditor.GameView,UnityEditor");
        if (t == null) return null;
        var getMainGameView = t.GetMethod("GetMainGameView", BindingFlags.NonPublic | BindingFlags.Static);
        if (getMainGameView == null) return null;
        var res = getMainGameView.Invoke(null, null) ?? GetWindow(t);
        return (EditorWindow)res;

    }

上記のようにReflectionで取得してGameViewのRenderTextureを無理やり拾ってきて表示しています。
リフレクションでGameViewを出してるWindowを取得して
その中のRenderTextureを取得している感じになります。
そのためタブ切り替えとかでGameViewが隠れると表示が更新されなくなります。

また、OSによってUVが反転したりしてしまったので
シェーダーでその辺り直して二枚合成して表示しています。
全体のコードはこちらからDLできます。

反響

社内でデザイナーさんがUIの配置調整をやっているため
こちらのツールを利用してかなり楽に配置できているようです。
作ってよかった!思っております。
パッケージで欲しい人はこちら

*画像を重ねる際に専用のシェーダーを利用してるのでそちらも必要になります

参考にしたサイト
http://d.hatena.ne.jp/shinriyo/20140807/p2

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity(Shader)】Shader初心者が送る水面表現

屈折

今回作るのはタイトル通り水面表現です。
具体的には屈折の表現について頑張ります。

屈折デモ

Water.gif

この表現については検索したらほぼ答えのものが出てきたので助かりました。

【参考リンク】:【Unity】【シェーダ】GrabPassを使って歪みシェーダを作る

屈折Shader

LIGHT11より拝借
Shader "Wave"
{
    Properties
    {
        _DistortionTex("Distortion Texture(RG)", 2D) = "grey" {}
        _DistortionPower("Distortion Power", Range(0, 1)) = 0
    }

        SubShader
        {
            Tags {"Queue" = "Transparent" "RenderType" = "Transparent" }

            Cull Back
            ZWrite On
            ZTest LEqual
            ColorMask RGB

            GrabPass { "_GrabPassTexture" }

            Pass {

                CGPROGRAM
               #pragma vertex vert
               #pragma fragment frag

               #include "UnityCG.cginc"

                struct appdata {
                    half4 vertex  : POSITION;
                    half4 texcoord  : TEXCOORD0;
                };

                struct v2f {
                    half4 vertex  : SV_POSITION;
                    half2 uv  : TEXCOORD0;
                    half4 grabPos : TEXCOORD1;
                };

                sampler2D _DistortionTex;
                half4 _DistortionTex_ST;
                sampler2D _GrabPassTexture;
                half _DistortionPower;

                v2f vert(appdata v)
                {
                    v2f o = (v2f)0;

                    o.vertex = UnityObjectToClipPos(v.vertex);
                    o.uv = TRANSFORM_TEX(v.texcoord, _DistortionTex);
                    o.grabPos = ComputeGrabScreenPos(o.vertex);

                    return o;
                }

                fixed4 frag(v2f i) : SV_Target
                {
                    // w除算
                    half2 uv = half2(i.grabPos.x / i.grabPos.w, i.grabPos.y / i.grabPos.w);

                    // Distortionの値に応じてサンプリングするUVをずらす
                    half2 distortion = tex2D(_DistortionTex, i.uv + _Time.x*0.1f).rg - 0.5;
                    distortion *= _DistortionPower;

                    uv = uv + distortion;
                    return tex2D(_GrabPassTexture, uv);
                }
                ENDCG
            }
        }
}

_Time

時間の経過によってUVをスクロールさせたかったので
_Timeを追記しました。時間が経過すればするほど値が大きくなると理解しました。

_Timeはx,y,z,wを指定して任意の速度に変更可能です。
(x,y,x,w) = (t/20, t, t*2, t*3)

ただ、UVの値って0~1なのに加算し続けてスクロールするのはなぜだろう?と疑問に思いました。
その疑問に関しては下記リンクの動画を見ればスッキリです。
0~1の範囲を超えても大丈夫ってことですね。

【参考リンク】:UV座標について

GrabPass

GrabPassについて、記事にいろんな表現がありましたが、私が一番しっくり来たのは

このパスが実行される時点のレンダリング結果を取得できる特殊なパス

という説明です。

引用リンク:【Unity】【シェーダ】GrabPassの説明とシェーダの実装例

色を付ける

合ってるかは置いといて色は付きました。

WaterAddColor.gif

ただ、本来は水に色があるのではなく、
空の色が映りこんでいるはずなのでここらへんは改良の余地有りです。
しかも、背景に乗算して色が乗っているので青くなりませんでした。
どうやったらきれいになるかもう少し調べます。

色付きの屈折Shader

Shader "Wave"
{
    Properties
    {
        _DistortionTex("Distortion Texture(RG)", 2D) = "grey" {}
        _DistortionPower("Distortion Power", Range(0, 1)) = 0
        _Color("WaterColor", Color) = (0,0,0,0)
    }

        SubShader
        {
            Tags {"Queue" = "Transparent" "RenderType" = "Transparent" }

            Cull Back
            ZWrite On
            ZTest LEqual
            ColorMask RGB

            GrabPass { "_GrabPassTexture" }

            Pass {

                CGPROGRAM
               #pragma vertex vert
               #pragma fragment frag

               #include "UnityCG.cginc"

                struct appdata {
                    half4 vertex  : POSITION;
                    half4 texcoord  : TEXCOORD0;
                };

                struct v2f {
                    half4 vertex  : SV_POSITION;
                    half2 uv  : TEXCOORD0;
                    half4 grabPos : TEXCOORD1;
                };

                sampler2D _DistortionTex;
                half4 _DistortionTex_ST;
                sampler2D _GrabPassTexture;
                half _DistortionPower;
                half4 _Color;

                v2f vert(appdata v)
                {
                    v2f o = (v2f)0;

                    o.vertex = UnityObjectToClipPos(v.vertex);
                    o.uv = TRANSFORM_TEX(v.texcoord, _DistortionTex);
                    o.grabPos = ComputeGrabScreenPos(o.vertex);

                    return o;
                }

                fixed4 frag(v2f i) : SV_Target
                {
                    // w除算
                    half2 uv = half2(i.grabPos.x / i.grabPos.w, i.grabPos.y / i.grabPos.w);

                    // Distortionの値に応じてサンプリングするUVをずらす
                    half2 distortion = UnpackNormal(tex2D(_DistortionTex, i.uv + _Time.x * 0.1f)).rg;
                    distortion *= _DistortionPower;

                    uv += distortion;
                    return tex2D(_GrabPassTexture, uv)* _Color;
                }
                ENDCG
            }
        }
}

屈折の仕方

Textureに影響して屈折の仕方が変わります。

なぜ変わるのかは完全には理解できませんでした。
下記リンクからノーマルマップの凹凸情報だけ取り出していると勝手に理解しました。

【参考リンク】:Unity シェーダーチュートリアル屈折表現

例えば、こういうノーマルマップを用意したとします。
NRandom200.PNG

上空から見ると、凹凸情報が反映されているのがよくわかります。

NWave200.gif

ただ、コメントアウトされている方のコードでもそれっぽくなったのが腑に落ちていません。
凹凸情報の無いただのテクスチャーでも良い感じになっちゃうのは一体なんなんでしょうね。。。

//half2 distortion = tex2D(_DistortionTex, i.uv + _Time.x*0.1f);
  half2 distortion = UnpackNormal(tex2D(_DistortionTex, i.uv + _Time.x * 0.1f)).rg;

ノイズ画像

下記記事で作成した画像を利用して解説します。

【ImageJ Fiji, Python】砂嵐画像を任意のピクセル数で簡単に生成する方法

20×20

20.PNG

Wave20.gif

200×200

200.PNG

Wave200.gif

おわりに

Shader難しいですね~。早く完全に理解したい。。。

参考リンク

Unityシェーダプログラム入門 UnlitShaderの要素を全て解説

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

強制横スクロールアクションゲームを作る_1 ~Unityで画像をObjectとして使う,Colliderをつける~

はじめに

ゲームを作る練習として初心者がUnityを使って、強制横スクロールアクションゲームを作るお話です。出来るだけ細かくどのような作業をしたか書いていくつもりです。

仕様

自転車に乗った人(=Player)が穴に落ちないようにジャンプして進むシンプルなゲームです。

  • Playerは右方向に等速で進んでジャンプは3段ジャンプまで
  • 床がランダムに生成されて、大きすぎる穴ができないように
  • Playerが床の横側にぶつかった時、画面下に落ちた時にゲームオーバー
  • 右上に進んだ距離を表示
  • ゲームオーバーになるとゲーム終了の画面でその距離を表示

開発

ものすごくシンプルな仕様を元にゲームを作り始めました。
今回使用したUnityのバージョンは2019.2.6f1で、2Dのプロジェクトにしました。

1.”自転車に乗った人”を作成

まずは自転車に乗った人の絵をpixivというwebブラウザで使えるツールでお絵描きして作りました。

ログインを済ませたら”描く”を選びます。
スクリーンショット 2019-11-30 2.22.00.png
できた絵がこちらです。なかなか味のある感じになりました笑
右上のボタンからPCにPNG形式で保存することができます。
スクリーンショット 2019-11-30 3.00.53.png
スクリーンショット 2019-11-30 2.57.21.png
この画像をゲームでそのまま使おうとすると背景の白い部分もオブジェクトとみなされてしまいます。
そこで背景を透明にしました。方法は他の人がまとめてくれたものを参考にしました。

背景が透明になった絵を(Unity内)Projectタブの中にあるAssetsにドラッグしてUnity内で使用できるようにしました。
その後、HierarchyにAssets内の画像をドラッグで持ってきて以下のような状態にしました。
スクリーンショット 2019-11-29 21.22.41.png

2. ”自転車に乗った人”の物理挙動

自転車に乗った人(=Player)には物理特性が必要なので、Rigidbody2Dを使いました。
Playerオブジェクトを選択した状態でInspector内
Add Component --> Physics 2D --> Rigidbody 2D

さらに床(仮)を準備してPlayerの物理挙動を確かめます。

床(仮)の作成にあたっては、四角形がデフォルトの2D Objectに含まれていないため、”Unityの2Dで四角形や三角形などの基本的なスプライトを使う方法”を参考に作成しました。

Playerと床(仮)が接触できるようにCollider (コライダー)を使用しました。Colliderがどのようなものかは”『Collider』をマスターする!”が参考になります。

まず床(仮)とPlayerにBoxCollider2Dをつけました。次にPlayerの前輪と後輪にあたる部分にCircleCollider2Dをつけました。
Add Component --> Physics 2D --> BoxCollider 2D, CircleCollider 2D

Colliderのタブの中にあるEdit Colliderという四角いボタンを押すことでColliderの形をSceneタブ内で編集できます。CircleCollider2DについてはColliderのタブ内Offsetのy座標の位置とRadius(半径)を揃えることでタイヤの高さを揃えました。ちなみに床(仮)には物理特性は不要なのでRigidbody2Dは使っていません。
スクリーンショット 2019-11-30 0.11.46.png

この状態で再生ボタンを押すとPlayerが床(仮)に着地します。

まとめ

今回は自転車に乗った人をUnity内に用意して、物理特性とColliderを与えました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unity のクリック、タッチに関してしったこと

1. 前提

Tech Academyのピンボールの発展課題で、以下のようなものがあった。

下記の条件を満たしてください

1. 画面の真ん中より左側でタップした時は左フリッパーを動かしてください
2. 画面の真ん中より右側でタップした時は右フリッパーを動かしてください
3. 左右で同時にタップした時も左右のフリッパーが正しく動くようにしましょう
4. タップが終わった時にフリッパーを元の位置に戻してください

ヒント

・・・中略・・・

タッチ操作の動作検証にはUnityRemoteというアプリを使いましょう

メンターの方からネットで色々調べてやってみてくださいということだったので、ググってみると以下のような記事が見つかり、これを参考にプログラムを組んだ。

1.1. みつかった記事

1., 2.関連

【Unity】スマホのタップ判定、画面の右側か左側か判断する

要点

  • スクリーン横幅サイズの取得: Screen.widthで取得できる
  • クリックが画面右側であるかの判断: if(Input.mousePosition.x >= Screen.width / 2)で判断できる
    →マウスクリックのx座標になっているのでこれをタッチされているx座標に置き換えればよい

3., 4.関連

Unityでマルチタッチ入力を扱う

要点

  • 実行環境がタッチ入力に対応しているか?の判断 → if(Input.touchSupported)で判断する
  • 現在のタッチ数の取得 → Input.touchCountで取得できる
  • 各タッチの座標、状態を取得する → Input.touches[i]を使う
    各タッチの座標はInput.touches[i].position.x(y,z座標も同様)で取得でき、
    各タッチの状態はInput.touches[i].phaseで取得できる 状態としては以下のものがある
    • TouchPhase.Began: 指が画面をタッチした
    • TouchPhase.Moved: 指が画面上で動いた
    • TouchPhase.Stationary: 指が画面に触れているが動いていない
    • TouchPhase.Ended: 画面から指を離した
    • TouchPhase.Canceled: システムによってタッチの追跡がキャンセルされた
      (Unity公式によると、ユーザーがデバイスを顔に当てたり、システムが追跡できる以上のタッチを同時に適用した場合に発生することがあるらしい。)

1.2. 見つかった記事を元に書いてみたコード

    void Update() {

        //画面左側がタッチされたとき左フリッパーを動かす
        if(IsLeftDisplayTouch() && tag == "LeftFripperTag") {
            SetAngle(this.flickAngle);
        }

        //画面右側がタッチされたとき右フリッパーを動かす
        if(IsRightDisplayTouch() && tag == "RightFripperTag") {
            SetAngle(this.flickAngle);
        }

        //画面左側のタッチがなくなったとき左フリッパーを元に戻す
        if(!IsLeftDisplayTouch() && tag == "LeftFripperTag") {
            SetAngle(this.defaultAngle);
        }

        //画面右側のタッチがなくなったとき右フリッパーを元に戻す
        if(!IsRightDisplayTouch() && tag == "RightFripperTag") {
            SetAngle(this.defaultAngle);
        }
    }

...(中略)...

    //画面左側がタッチされているか(少なくとも1つ以上)
    private bool IsLeftDisplayTouch() {
        // Unityエディターの場合
        if(Application.isEditor) {
            // マウスで左クリックされているか
            if(Input.GetMouseButton(0) && (Input.mousePosition.x < Screen.width / 2.0f)){
                return true;
            }
        }
        // そうでない場合
        else {
            // タッチ入力に対応している場合
            if(Input.touchSupported) {
                Debug.Log("IsLeftDisplayTouch:Screen.width:" + Screen.width);

                // 各タッチについてそのx座標がスクリーン幅/2よりも小さいものがあるならば画面左側タッチ判定
                for(int i = 0; i < Input.touchCount; i++) {
                    Debug.Log("IsLeftDisplayTouch:position.x[" + i + "]:" + Input.touches[i].position.x);
                    if(Input.touches[i].position.x < Screen.width / 2.0f) { 
                        return true;
                    }
                }
            }
        }

        return false;
    }

    //画面右側がタッチされているか(少なくとも1つ以上)
    private bool IsRightDisplayTouch() {

        // Unityエディターの場合
        if(Application.isEditor) {
            // マウスで左クリックされているか
            if(Input.GetMouseButton(0) && (Input.mousePosition.x >= Screen.width / 2.0f)) {
                return true;
            }
            Debug.Log("isEditor:true");
        }
        // そうでない場合
        else {
            // タッチ入力に対応している場合
            if(Input.touchSupported) {
                Debug.Log("IsRightDisplayTouch:Screen.width:" + Screen.width);
                // 各タッチについてそのx座標がスクリーン幅/2よりも小さいものがあるならば画面左側タッチ判定
                for(int i = 0; i < Input.touchCount; i++) {
                    Debug.Log("IsRightDisplayTouch:position.x[" + i + "]:" + Input.touches[i].position.x);
                    if(Input.touches[i].position.x >= Screen.width / 2.0f) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

これを書いたときの発想

  • Unityエディター上ではマウスクリック、実機上ではタッチでフリッパーが操作できるようにしておくと、今後ピンボールプロジェクトをベースにテストしたいときに役に立ちそうだからやっておこう。
  • なんかボタンとかタッチの状態って結構種類あるけど、以下の形でフリッパー操作の処理を分岐させる方が処理がシンプルでわかりやすくていいんじゃない?
    • 押してる:フリッパー上げる
    • 押してない:フリッパー下げる
  • ちゅうわけで、Unityエディター側のマウスクリックの方は左クリック関連で以下の状態があるんだけどGetMouseButton(0)のTrue/False利用すればいいでしょ。押した瞬間、離した瞬間とかそんな重要か?と考えた
    • GetMouseButtonDown(0): ボタンを押したフレーム中にtrueを返す。ボタンを離してもう一度押すまで、trueは返さない。
    • GetMouseButton(0): ボタンが押されているかどうか。ボタンが押されているときにTrue、話されているときにFalseを返す。
    • GetMouseButtonUp(0): ボタンを離したフレーム中にtrueを返す。ボタンを押してもう一度離すまで、Trueは返さない。
  • 実機側のタッチの方も同様の発想(押してる押してないだけわかればいい)だけど、そもそもタッチされている状態のときにInput.touches[i]が存在するはずだから、状態の判定すらいらないやん。こいつぁ楽だねと考えた
  • で、「左側がタッチされているか」メソッド、「右側がタッチされているか」メソッドを作って、それぞれのメソッド内でUnityエディター上での操作だったときはマウスの座標で判断、実機上での操作だったときはタッチの座標で判断でという大きな分岐をおけばやりたいこと全部満たせてうまくいけそうね、ということで上のコードにいたった。

問題点

  • まず、Unity Remoteで実機確認してみると、なぜかタッチが反応しない・・・・
    メンターの方に確認すると、「Unity Remote使っても、結局はUnity Editor上で動かしてるからApplication.isEditorはtrueになってしまう」とのこと。
    Application.isEditorは無能ないらない子なのかしら・・・
  • Input.touchSupportedも、Unity Editor上で動かしている扱いであり、多分Unityを動かしてるデバイスがMacBookProなのでfalseが返ってきてるっぽい。こやつもいらない子か・・・
  • あと、Unity Remoteは個人的には実務ではあんまり使わないです。実際の挙動を見る上ではやっぱり実機使ったほうが正確なのでビルドして確かめますとのこと。環境ごとに操作を変えるのは、下の記事のようにしてますとのことだった。
    Unity プラットフォーム判別 ・・・なるほどねぇ。ifdefとかむかーしむかしにみた記憶がありますね・・・・存在忘れてました・・・
  • さらに、メンターの方に指摘されたこととして、この処理だと、画面左側で指が触れたときに左フリッパーが上がって、触れた状態のまま画面右側までスライドさせると左フリッパーが下がって右フリッパーが上がる処理になる。Touch.Began,Endedを使ってタッチされた、離されたときだけ処理するようにした方が自然では?加えて、タッチされている間は常に処理が走ることになるから、負荷的な意味でも意味のない処理はやめた方がよい、というものがあった。
    ・・・これは非常に納得した。タッチ、クリックの状態を使う場面ってこういうときなんだ・・・・

修正方針

問題点に対する修正方針は以下。

  • 環境ごとに操作を変えるために、ifdef使ってみる
  • 実機確認はビルドして行う
  • 押した直後、離した直後を拾ってフリッパー操作を行う処理にする

修正後のコード

<作成中>

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unityについて知ったこと

1. はじめに

Tech Academyのピンボールの発展課題で、以下のようなものがあった。

下記の条件を満たしてください
 条件1. 画面の真ん中より左側でタップした時は左フリッパーを動かしてください
 条件2. 画面の真ん中より右側でタップした時は右フリッパーを動かしてください
 条件3. 左右で同時にタップした時も左右のフリッパーが正しく動くようにしましょう
 条件4. タップが終わった時にフリッパーを元の位置に戻してください

ヒント
 ・・・中略・・・
 タッチ操作の動作検証にはUnityRemoteというアプリを使いましょう

課題を解く上でググって調べて、色々考えて、コード書いてみて、メンターの方に指摘もらって、また調べてコード修正して・・・
という一連の活動を通して色々と学ぶことができた。

この記事はその一連の活動の記録と学んだことをまとめたものである。

2. 発展課題を解くヒントを探す

条件1., 2.について知りたいこと

ズバリ画面タッチが左なのか右なのかをどう判定すればいいのか
見つかった記事と要点を以下にまとめる。

見つかった記事

【Unity】スマホのタップ判定、画面の右側か左側か判断する

要点

  • スクリーン横幅サイズの取得: Screen.widthで取得できる
  • クリックが画面右側であるかの判断: if(Input.mousePosition.x >= Screen.width / 2)で判断できる
    →マウスクリックのx座標になっているので、スマホで実行するにあたっては、これをタッチされているx座標に置き換えればよいはず

条件3., 4.について知りたいこと

ズバリ複数のタッチをどう扱えばよいのか(ここまでのカリキュラムでは複数の入力には触れてこなかった)。
見つかった記事と要点を以下にまとめる。

見つかった記事

Unityでマルチタッチ入力を扱う

要点

  • 実行環境がタッチ入力に対応しているか?の判断 → if(Input.touchSupported)で判断する
  • 現在のタッチ数の取得 → Input.touchCountで取得できる
  • 各タッチの座標、状態を取得する → Input.touches[i]を使う
    各タッチの座標はInput.touches[i].position.x(y,z座標も同様)で取得でき、
    各タッチの状態はInput.touches[i].phaseで取得できる 状態としては以下のものがある
    • TouchPhase.Began: 指が画面をタッチした
    • TouchPhase.Moved: 指が画面上で動いた
    • TouchPhase.Stationary: 指が画面に触れているが動いていない
    • TouchPhase.Ended: 画面から指を離した
    • TouchPhase.Canceled: システムによってタッチの追跡がキャンセルされた
      (Unity公式によると、ユーザーがデバイスを顔に当てたり、システムが追跡できる以上のタッチを同時に適用した場合に発生することがあるらしい。)

3. 得られた情報を元に考えて作戦立ててコードを書く

作戦

  • Unityエディター上ではマウスクリック、実機上ではタッチでフリッパーが操作できるようにしておくと、今後ピンボールプロジェクトをベースにテストしたいときに役に立ちそうだからやっておこう。
  • なんかボタンとかタッチの状態って結構種類あるけど、以下の形でフリッパー操作の処理を分岐させる方が処理がシンプルでわかりやすくていいんじゃない?押した瞬間、離した瞬間とかの状態も取得できるけど、それってそんな重要か?いらんくね?と考えた。
    • 押してる:フリッパー上げる
    • 押してない:フリッパー下げる
  • ちゅうわけで、Unityエディター側のマウスクリックの方は左クリック関連で以下の状態があるんだけどGetMouseButton(0)のTrue/False利用すればいいでしょ。と考えた
    • GetMouseButtonDown(0): ボタンを押したフレーム中にtrueを返す。ボタンを離してもう一度押すまで、trueは返さない。
    • GetMouseButton(0): ボタンが押されているかどうか。ボタンが押されているときにTrue、話されているときにFalseを返す。
    • GetMouseButtonUp(0): ボタンを離したフレーム中にtrueを返す。ボタンを押してもう一度離すまで、Trueは返さない。
  • 実機側のタッチの方も同様の発想(押してる押してないだけわかればいい)だけど、そもそもタッチされている状態のときにInput.touches[i]が存在するはずだから、その要素がある=タッチされてるということなんで、そもそも状態の取得・判定する必要すらないやん。タッチ入力できるデバイスの方がむしろ楽だねと考えた
  • 上記を踏まえ、全体的な作戦として「左側がタッチされているか」メソッド、「右側がタッチされているか」メソッドを作って、それぞれのメソッド内でUnityエディター上での操作だったときはマウスの座標で判断、実機上での操作だったときはタッチの座標で判断でという大きな分岐をおけばやりたいこと全部満たせてうまくいきそうね、ということで下のコードにいたった。

コードその1

    void Update() {

        //画面左側がタッチされたとき左フリッパーを動かす
        if(IsLeftDisplayTouch() && tag == "LeftFripperTag") {
            SetAngle(this.flickAngle);
        }

        //画面右側がタッチされたとき右フリッパーを動かす
        if(IsRightDisplayTouch() && tag == "RightFripperTag") {
            SetAngle(this.flickAngle);
        }

        //画面左側のタッチがなくなったとき左フリッパーを元に戻す
        if(!IsLeftDisplayTouch() && tag == "LeftFripperTag") {
            SetAngle(this.defaultAngle);
        }

        //画面右側のタッチがなくなったとき右フリッパーを元に戻す
        if(!IsRightDisplayTouch() && tag == "RightFripperTag") {
            SetAngle(this.defaultAngle);
        }
    }

...(中略)...

    //画面左側がタッチされているか(少なくとも1つ以上)
    private bool IsLeftDisplayTouch() {
        // Unityエディターの場合
        if(Application.isEditor) {
            // マウスで左クリックされているか
            if(Input.GetMouseButton(0) && (Input.mousePosition.x < Screen.width / 2.0f)){
                return true;
            }
        }
        // そうでない場合
        else {
            // タッチ入力に対応している場合
            if(Input.touchSupported) {
                Debug.Log("IsLeftDisplayTouch:Screen.width:" + Screen.width);

                // 各タッチについてそのx座標がスクリーン幅/2よりも小さいものがあるならば画面左側タッチ判定
                for(int i = 0; i < Input.touchCount; i++) {
                    Debug.Log("IsLeftDisplayTouch:position.x[" + i + "]:" + Input.touches[i].position.x);
                    if(Input.touches[i].position.x < Screen.width / 2.0f) { 
                        return true;
                    }
                }
            }
        }

        return false;
    }

    //画面右側がタッチされているか(少なくとも1つ以上)
    private bool IsRightDisplayTouch() {

        // Unityエディターの場合
        if(Application.isEditor) {
            // マウスで左クリックされているか
            if(Input.GetMouseButton(0) && (Input.mousePosition.x >= Screen.width / 2.0f)) {
                return true;
            }
            Debug.Log("isEditor:true");
        }
        // そうでない場合
        else {
            // タッチ入力に対応している場合
            if(Input.touchSupported) {
                Debug.Log("IsRightDisplayTouch:Screen.width:" + Screen.width);
                // 各タッチについてそのx座標がスクリーン幅/2よりも小さいものがあるならば画面左側タッチ判定
                for(int i = 0; i < Input.touchCount; i++) {
                    Debug.Log("IsRightDisplayTouch:position.x[" + i + "]:" + Input.touches[i].position.x);
                    if(Input.touches[i].position.x >= Screen.width / 2.0f) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

4. 書いたコードの問題点と修正方針

問題点

  • まず、Unity Remoteで実機確認してみると、なぜかタッチが反応しない・・・・
    メンターの方に確認すると、「Unity Remote使っても、結局はUnity Editor上で動かしてるからApplication.isEditorはtrueになってしまう」とのこと。
    Application.isEditorは無能ないらない子なのかしら・・・
  • Input.touchSupportedも、Unity Editor上で動かしている扱いであり、多分Unityを動かしてるデバイスがMacBookProなのでfalseが返ってきてるっぽい。こやつもいらない子か・・・
  • あと、Unity Remoteは個人的には実務ではあんまり使わないです。実際の挙動を見る上ではやっぱり実機使ったほうが正確なのでビルドして確かめますとのこと。環境ごとに操作を変えるのは、下の記事のようにしてますとのことだった。
    Unity プラットフォーム判別 ・・・なるほどねぇ。ifdefとかむかーしむかしにみた記憶がありますね・・・・存在忘れてました・・・
  • さらに、メンターの方に指摘されたこととして、この処理だと、画面左側で指が触れたときに左フリッパーが上がって、触れた状態のまま画面右側までスライドさせると左フリッパーが下がって右フリッパーが上がる処理になる。Touch.Began,Endedを使ってタッチされた、離されたときだけ処理するようにした方が自然では?加えて、タッチされている間は常に処理が走ることになるから、負荷的な意味でも意味のない処理はやめた方がよい、というものがあった。
    ・・・これは非常に納得した。タッチ、クリックの状態を使う場面ってこういうときなんだ・・・・

修正方針

問題点に対する修正方針は以下。

  1. 環境ごとに操作を変えるために、ifdef使ってみる
  2. 実機確認はビルドして行う
  3. 押した直後、離した直後を拾ってフリッパー操作を行う処理にする

修正後のコード

なんか修正方針以外にも色々思うことがあって修正しまくってたら
かなり変わった・・・・

  • ifdef使おうと思ったが、なんかVisual Studioで意図したようなインデントをしてくれなくなるのでApplication.platformを使う形にした。
    • 疑問:うーん、メンターさんはインデントどうしてたんだろう。ちゅうかどういう単位でifdef使ってるんだろう。自分は環境で処理がわかれるところピンポイントで使おうとしてたけど、ソース全体に関してifdef使うのかな。
  • フリッパーの状態を確認してすでに下がっているなら下げる操作をするためのタッチ状態の確認とか無駄なことしないようにした
  • 上げ下げするタッチは複数存在する場合、下げ操作対象は上げ操作をしたタッチという仕様にして、上げ操作をしたタッチをトラッキングしてそのタッチが離れたら下げるという形にした。
  • トラッキングはfingerIdで行うのだが、フリッパー操作対象のfingerIdが存在しない、を表現するのにnullをセットできるint?を使った。fingerIdはint型なのだが、タッチに対して付与する値の範囲が定義されていないので困った結果int?という型がC#では用意されていると知って。
  • フリッパーが上がっている場合は上げ操作を受け付けないので、上げ操作をしようとするタッチは無視される。
  • UnityEditor上での操作のときにはタッチをマウス左クリックで処理しようとしたけど、左右矢印に戻した。マウスクリックだと左右フリッパーを同時操作できないので。
using UnityEngine;
using System.Collections;

public class FripperController : MonoBehaviour {
    //HingeJointコンポーネントを入れる
    private HingeJoint myHingeJoint;

    //初期の傾き
    private float defaultAngle = 20;
    //弾いた時の傾き
    private float flickAngle = -20;

    // フリッパーの状態
    /// 下がっている
    private const int FRIPPER_STATE_DOWN = 0;
    /// 上がっている
    private const int FRIPPER_STATE_UP = 1;
    int fripperState = FRIPPER_STATE_DOWN;

    // タッチ非対応デバイス用のメンバ
    /// フリッパー操作キー
    UnityEngine.KeyCode targetKeyCode;

    // タッチ対応デバイス用のメンバ(本当は別のクラスを用意して管理したい)
    /// フリッパー操作対象のタッチID(nullは「操作対象のタッチがない」を表す)
    int? fripperControlTouchId = null;
    /// フリッパー操作対象のタッチ
    Touch fripperControlTouch;
    /// フリッパー操作を受け付けるタッチのx座標範囲(タッチ対応デバイス用)
    float fripperControlTouchMaxX = 0.0f;
    float fripperControlTouchMinX = 0.0f;

    // Use this for initialization
    void Start() {
        // UnityEditorでアプリ実行する場合
        if(Application.platform == RuntimePlatform.OSXEditor) {
            Debug.Log("Platform is Unity Editor");

            // オブジェクトのタグによって操作キーを定義
            if(tag == "LeftFripperTag") targetKeyCode = KeyCode.LeftArrow;
            else if(tag == "RightFripperTag") targetKeyCode = KeyCode.RightArrow;
        }
        // iPhone実機でアプリ実行する場合
        else if(Application.platform == RuntimePlatform.IPhonePlayer) {
            Debug.Log("Platform is iPhone");

            // オブジェクトのタグによってタッチ操作を受けつけるx座標範囲を定義
            if(tag == "LeftFripperTag") {
                fripperControlTouchMaxX = Screen.width / 2.0f;
                fripperControlTouchMinX = 0.0f;
            }
            else if(tag == "RightFripperTag") {
                fripperControlTouchMaxX = (float)Screen.width;
                fripperControlTouchMinX = Screen.width / 2.0f;
            }
        }

        // フリッパー操作対象のタッチインスタンス生成
        fripperControlTouch = new Touch();

        //HingeJointコンポーネント取得
        this.myHingeJoint = GetComponent<HingeJoint>();

        //フリッパーの傾きを設定
        SetAngle(this.defaultAngle);
    }

    // Update is called once per frame
    void Update() {
        // UnityEditorでアプリ実行
        if(Application.platform == RuntimePlatform.OSXEditor) {
            MoveFripperOnUnityEditor();
        }
        // iPhone実機でアプリ実行
        else if(Application.platform == RuntimePlatform.IPhonePlayer) {
            MoveFripperOnIphone();
        }
    }

    /* フリッパーを動かす(UnityEritor上で実行している場合)
     * 
     * 仕様
     * ・フリッパーを動かす入力の種類:左右矢印キー
     * ・フリッパーの操作:
     *  ・フリッパーを上げるトリガー:矢印キーが押された瞬間
     *  ・フリッパーを下げるトリガー:矢印キーが離された瞬間
     */
    private void MoveFripperOnUnityEditor() {
        // フリッパーを下げる操作
        // フリッパーがすでに下がっているならば何もしない
        if(fripperState == FRIPPER_STATE_DOWN) {
            Debug.Log("Fripper(tag:" + tag + ") is already down.");
        }
        else {
            // フリッパー操作キーを離した場合
            if(Input.GetKeyUp(targetKeyCode)) {
                // フリッパーを下げる
                DownFripper();
                // フリッパーの状態を「下がっている」にする
                fripperState = FRIPPER_STATE_DOWN;
                Debug.Log("Fripper(tag:" + tag + ") is down.");
            }
        }

        // フリッパーを下げる操作
        // フリッパーがすでに上がっているならば何もしない
        if(fripperState == FRIPPER_STATE_UP) {
            Debug.Log("Fripper(tag:" + tag + ") is already up.");
        }
        else {
            // フリッパー操作キーを押した直後である場合
            if(Input.GetKeyDown(targetKeyCode)) {
                // フリッパーを上げる
                UpFripper();
                // フリッパーの状態を「上がっている」にする
                fripperState = FRIPPER_STATE_UP;
                Debug.Log("Fripper(tag:" + tag + ") is up.");
            }
        }
    }

    /* フリッパーを動かす
     * 
     * 仕様
     * ・フリッパーを動かす入力の種類:タッチ(複数タッチに対応)
     * ・フリッパーの操作:
     *  ・フリッパーを上げるトリガー:フリッパーの操作範囲内でタッチが発生した瞬間
     *  ・フリッパーを上げるトリガー:フリッパーを上げたタッチが解除された瞬間
     *   ※例えば画面左側でタッチして左フリッパーを上げた後、
     *    そのまま画面右側へスライドしてクリックを解除した場合、
     *    右フリッパーは上がらず、左フリッパーが下がる
     *  ・画面左側へ複数のタッチがあった場合は、先に発生したタッチが左フリッパーの操作対象となる
     *   ※例えば画面左側へ人差し指、中指の順にタッチがあった場合、
     *    人指し指のみが左フリッパーの操作対象となり、中指が画面から離れても
     *    左フリッパーは下がらない
     *  ・画面の左右をまたがり複数の入力があった場合は、画面左側で先に発生した入力が左フリッパーの
     *   操作対象となり、画面右側で先に発生した入力が画面右側の操作対象となる。
     *   ※例えば画面左側へ左人指し指、左中指、画面右側へ右人指し指、右中指の順に
     *    タッチがあった場合、左人指し指が左フリッパーの操作対象となり、
     *    右人差し指が右フリッパーの操作対象となる
     */
    private void MoveFripperOnIphone() {
        // フリッパーを下げる操作
        // フリッパーがすでに下がっているならば何もしない
        if(fripperState == FRIPPER_STATE_DOWN) {
            Debug.Log("Fripper(tag:" + tag + ") is already down.");
        }
        else {
            // 全タッチを調査
            for(int i = 0; i < Input.touchCount; i++) {
                Debug.Log("touches[" + i + "]");
                Debug.Log("  position.x : " + Input.touches[i].position.x);
                Debug.Log("  phase : " + Input.touches[i].phase);

                // フリッパー操作対象のタッチである場合
                if(Input.touches[i].fingerId == fripperControlTouchId) {
                    // フリッパー操作対象タッチの状態を取得する
                    fripperControlTouch.phase = Input.touches[i].phase;
                    // フリッパー操作対象のタッチの状態が「解除直後」だった場合
                    if(fripperControlTouch.phase == TouchPhase.Ended) {
                        // フリッパーを下げる
                        DownFripper();
                        // フリッパー操作対象のタッチIDを破棄する
                        fripperControlTouchId = null;
                        // フリッパーの状態を「下がっている」にする
                        fripperState = FRIPPER_STATE_DOWN;
                        Debug.Log("Fripper(tag:" + tag + ") is down.");
                        break;
                    }

                }
            }
        }

        // フリッパーを上げる操作
        // フリッパー操作対象のタッチが存在する(=フリッパーがすでに上がっている)ならば何もしない
        if(fripperState == FRIPPER_STATE_UP) {
            Debug.Log("Fripper(tag:" + tag + ") is already up.");
        }
        else {
            // 全タッチを調査
            for(int i = 0; i < Input.touchCount; i++) {
                Debug.Log("touches[" + i + "]");
                Debug.Log("  position.x : " + Input.touches[i].position.x);
                Debug.Log("  phase : " + Input.touches[i].phase);

                // タッチの状態が「発生直後」かつ「タッチしているx座標が本フリッパー操作範囲内」である場合
                if(Input.touches[i].phase == TouchPhase.Began &&
                    (Input.touches[i].position.x >= fripperControlTouchMinX) &&
                    (Input.touches[i].position.x < fripperControlTouchMaxX)) {
                    // フリッパーを上げる
                    UpFripper();
                    // フリッパー操作対象のタッチIDを記憶
                    fripperControlTouchId = Input.touches[i].fingerId;
                    // フリッパーの状態を「上がっている」にする
                    fripperState = FRIPPER_STATE_UP;
                    Debug.Log("Fripper(tag:" + tag + ") is up.");
                    break;
                }
            }
        }
    }

    //指定したタグのフリッパーを上げる
    private void UpFripper() {
        SetAngle(this.flickAngle);
    }

    //指定したタグのフリッパーを下げる
    private void DownFripper() {
        SetAngle(this.defaultAngle);
    }

    //フリッパーの傾きを設定
    public void SetAngle(float angle) {
        JointSpring jointSpr = this.myHingeJoint.spring;
        jointSpr.targetPosition = angle;
        this.myHingeJoint.spring = jointSpr;
    }

}

5. 知ったこと

  • 画面幅はScreen.width, Heightで取れる。これらはint型
  • タッチした画面の座標はTouch.position.x,y,zで取れるけどこちらはfloat型
  • タッチはfingerIdでトラッキングできるけど、マウスはトラッキングできない(必要がないってことかな)
  • タッチ状態は押している、押していないだけに注目せず、押した瞬間、離された瞬間も見たほうが無駄な処理をさせずにすむ場合がある
  • C#にはint?というnullを取れる便利な型がある。無効な値を定義したいときに使える。
  • プラットフォームで処理を分けたい場合、「プラットフォーム依存コンパイル」(ifdef)か「Application.platform」を使う方法がある
  • ただしifdefはIDE上でインデントがうまく働かず、使いづらい。
  • MonoBehaviour継承クラスにおいて、コンストラクタは使わず、start()をその位置づけとするのが推奨されているらしい

他にも色々あったけどわすれた

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む