yu00’s blog

プログラミングに関する備忘録です

BlenderとUnityで山に囲まれたSkyboxを作る

はじめに

Blenderで山に囲まれたSkyboxを作り、
Unityで表示する方法について説明します。

Blender

アドオンの有効化

以下アドオンを有効にします

  • Edit>Preferences>Add-ons
    • Add Mesh ANT Landscape
    • Node Wrangler
    • Dynamic Sky

山と地面を作る

  • Shift+A>Empty>Plain Axes
    • Z方向に15°回転する
  • Shift+A>Plane
    • Groundという名前にする
    • サイズを10mにする
  • Shift+A>Mesh>Landscape
    • Y方向に5m移動する
    • Object>Set Origin>Origin to 3D Cursor
    • Object>Apply>All Transforms
    • Add Modifier>Array
      • Count : 24
      • Relative Offset : チェックを外す
      • Object Offset : チェック
        • Object : Empty

山と地面のテクスチャを設定する

今回は「https://ambientcg.com/」のテクスチャを使用します。

  • Landscape>Material>New
  • Shadingタブに移動
  • Ctrl+Shift+T
  • ambientcgでダウンロードしたデータをすべて選択
  • Planeも同様にテクスチャを設定する

太陽を作る

  • Shift+A>Light>Sun
    • 適当な位置に配置
    • Strength : 3
  • Shift+A>Mesh>Circle
    • Sun.Meshという名前にする
    • Edit ModeにしてFで面を張る
    • Sunと同じ位置まで移動し、スケールを調整
    • Material

空を作る

  • サイドバー>Dynamic sky>Create
  • World Properties>Dynamic_1を選択
  • 設定を適当に調整

レンダリング

  • Render Properties
    • Render Engine : Cycles
  • Shift+A>Camera
    • Location : 0, 0, 0.1
    • Rotation : 90, 0, 0
    • Object Data Properties
      • Type : Panoramic
      • Panorama Type : Equirectangular
  • Output Properties
    • Resolution : X 4096, Y 2048
    • File Format : Radiance HDR
  • Render>Render Image

Bloomをかける

  • Compositingタブ
  • Use Nodes
  • Shift+A>Filter>Glare
    • Fog Glow
    • Render Layers>ImageとGlare>Imageを接続
    • GlareをCtrl+Shift+左クリック
  • Render>View Render
    • Viwer Node
    • Image>Save As
    • 今回は「Mountain_Skybox.hdr」という名前で保存します

Unity

参考サイト

バージョン

  • Blender : 3.2.1
  • Unity : 2019.4.31f1

Unity ShaderLabで流れる雲を作る

はじめに

UnityのShaderLabを使って流れる雲を作る方法を説明します。
UVスクロールを使うことで雲がアニメーションします。
ノーマルマップを使うことで影をつけます。

テクスチャを作る

雲のテクスチャ

雲のテクスチャを作成します。今回はGIMPで作成します。

  • ファイル>新しい画像
    • 今回はサイズを2048pxとしました
  • レイヤー>透明部分>アルファチャンネルの追加
  • 背景画像を削除して透明にする
  • フィルター>下塗り>ノイズ>ソリッドノイズ
    • Tileableにチェック
  • 色>色を透明度に
    • Color : 黒を選択
  • ファイル>名前を付けてエクスポート
    • 今回は「Cloud.png」という名前でエクスポートします

ノーマルマップ

  • 雲のテクスチャで白黒画像を作るところまでは同様です
  • フィルター>汎用>法線マップ
    • Scaleを適当に調整します
  • ファイル>名前を付けてエクスポート
    • 今回は「CloudNormal.png」という名前でエクスポートします
  • UnityでTexture Type : Normal map に設定

マスクテクスチャ

外側を透明にするためのテクスチャを作成します。

真ん中は不透明で外側は透明なテクスチャです。
今回は「CloudMask.png」という名前で作成します。

モデルの作成

天球モデルを作成します。今回はBlenderで作成します。

  • Add>Mesh>UV Sphere
  • 下半分を削除します
  • Object>Shade Smooth
  • Add Modifier>Subdivision Surface
  • モディファイアを適用
  • UV>Unwrap
  • File>Export>FBX
    • 今回は「Cloud.fbx」という名前でエクスポートします

シェーダーの作成

Unityでシェーダーを作成します

  • Create>Shader>Standard Surface Shader

以下のように記述します

Shader "Custom/Cloud"
{

    Properties
    {
        // 色
        _Color("Color", Color) = (1,1,1,1)
        // メインテクスチャ
        _MainTex("Albedo (RGB)", 2D) = "white" {}
        // ノーマルマップスケール
        _BumpScale("Normal Scale", Float) = 1.0
        // ノーマルマップ
        [Normal] _BumpMap("Normal Map", 2D) = "bump" {}
        // マスクテクスチャ
        _MaskTex("Mask", 2D) = "white" {}
        // スクロールX速度
        _ScrollX("Scroll X Speed", float) = 0
        // スクロールY速度
        _ScrollY("Scroll Y Speed", float) = 0
    }
    SubShader
    {
        // 透過
        Tags { "RenderType" = "Transparent" "Queue" = "Transparent" }
        LOD 200
        // 背面も表示
        Cull Off

        CGPROGRAM
        // Physically based Standard lighting model, and enable shadows on all light types
        // 透過
        #pragma surface surf Standard fullforwardshadows alpha:blend

        // Use shader model 3.0 target, to get nicer looking lighting
        #pragma target 3.0

        sampler2D _MainTex;

        struct Input
        {
            float2 uv_MainTex;
            float2 uv_BumpMap;
            float2 uv_MaskTex;
        };

        fixed4 _Color;
        float _ScrollX, _ScrollY;
        sampler2D _MaskTex;
        sampler2D _BumpMap;
        half _BumpScale;

        // Add instancing support for this shader. You need to check 'Enable Instancing' on materials that use the shader.
        // See https://docs.unity3d.com/Manual/GPUInstancing.html for more information about instancing.
        // #pragma instancing_options assumeuniformscaling
        UNITY_INSTANCING_BUFFER_START(Props)
            // put more per-instance properties here
        UNITY_INSTANCING_BUFFER_END(Props)

        void surf(Input IN, inout SurfaceOutputStandard o)
        {
            // UVスクロール量を計算
            float2 scroll = float2(_ScrollX, _ScrollY) * _Time.y;

            // Albedo comes from a texture tinted by color
            fixed4 c = tex2D(_MainTex, IN.uv_MainTex + scroll) * _Color;
            o.Albedo = c.rgb;
            // ノーマルマップを計算
            o.Normal = UnpackScaleNormal(tex2D(_BumpMap, IN.uv_BumpMap + scroll), _BumpScale);

            // マスク
            fixed4 mask = tex2D(_MaskTex, IN.uv_MaskTex);
            o.Alpha = c.a * mask.a;
        }
        ENDCG
    }
    FallBack "Diffuse"
}

マテリアルの作成

Unityでマテリアルを作成します。

  • Create>Material
    • 今回は「Cloud.mat」という名前で作成します
  • Shader : Custom/Cloud
  • Albedo : Cloud.png
  • Normal Map : CloudNormal.png
  • Mask : CloudMask.png
  • Scroll X/Y : 適当な値

モデルの配置

Unityでモデルを配置します

  • Cloud.fbxをシーンに配置
  • Scallは今回500にしました
  • Mesh Renderer>Materials : Cloud.mat

参考サイト

バージョン

VRChatワールド開発テスト環境構築の例

VRChatの開発ツール導入ではなく、UdonSharpで開発・テストする上での
ノウハウをまとめた記事になります。
独学なのでこれが正しい・最適とは限らないことに注意です。
VRChatの説明ですが、ほかのプラットフォームでも使えるはずです。

方針

  • Unityはクラッシュする前提で構築する
    • クラッシュするとシーン・プレハブが壊れることがある
    • クラッシュしても大丈夫なように、シーン・プレハブはEditorで自動生成する
  • テスト用と製品用のスクリプトを分ける
    • テスト用はブレイクポイントが使えるようにVRChatによならいコードにする
    • テスト用から製品用のスクリプトを自動生成する

開発手順図

  1. テスト用スクリプトMockScriptを作成し、シーンMockSceneに配置する
  2. MockSceneのテストMockTestを行う
  3. MockScriptをビルドし製品用スクリプトProductScriptを作成、シーンProductSceneに配置する
  4. ProductSceneのテストをClientSimで行う
  5. ProductSceneをビルドしVRChatアプリを作成、ローカルテストを行う
  6. ProductSceneをアップロードしオンラインテストを行う

次の項から開発手順を詳しく説明していきます。

Assembly definitionの作成

プロジェクトの設定が終わったら、最初にAssembly definitionを作成します。
テスト用スクリプトのための「Mocksフォルダ」、
製品用スクリプトのための「Scriptsフォルダ」、
Editor拡張用のための「Editorフォルダ」以下に下記
Assembly definitionを作成します。

まず、Mocksフォルダ以下にAssembly definitionを作成します。
手順

  1. Create > Assembly definition
    • MockAssemblyという名前で保存する

次に、Scriptsフォルダ以下にAssembly definitionと
U# Assembly Definitionを作成します。
手順

  1. Create > Assembly definition
    • ProductAssemblyという名前で保存する
  2. Assembly Definition References
    • VRC.SDK3
    • VRC.SDKBase
    • VRC.Udon
    • UdonSharp.Runtime
  3. Apply
  4. Create > U# Assembly definition
  5. Source Assembly : ProductAssembly
  6. Apply

最後に、UdonSharpのEditorユーティリティを使えるようにするため、
Editorフォルダ以下にAssembly definition referenceを作成します。
手順

  1. Create > Assembly Definition Reference
  2. Assembly Definition : UdonSharp.Editor を選択
  3. Apply

テスト・製品用スクリプトの初期生成

Editor拡張でファイル名を入力すると、テスト・製品用スクリプト
自動生成するスクリプトを作成しました。
テスト用スクリプトは「Mocks/Mockスクリプト名.cs」、製品用スクリプト
「Scripts/スクリプト名.cs」という名前で配置するようにしました。

スクリプトは以下です。

using System.IO;
using UdonSharp;
using UnityEditor;
using UnityEngine;

namespace UdonSharpEditor
{
    internal class CreateScript : EditorWindow
    {
        public string Text;

        string TemplatePath = @"C:\Program Files\Unity\Hub\Editor\2019.4.31f1\Editor\Data\Resources\ScriptTemplates\81-C# Script-NewBehaviourScript.cs.txt";

        [MenuItem("My Window/My Editor")]
        public static void MyEditorWindow()
        {
            GetWindow(typeof(CreateScript));
        }
        void OnGUI()
        {
            Text = EditorGUILayout.TextField("Script", Text);
            if (GUILayout.Button("Create Script"))
            {

                CreateUSharpScript($"Assets/Scripts/{Text}.cs");
                ProjectWindowUtil.CreateScriptAssetFromTemplateFile(TemplatePath, $"Assets/Mocks/Mock{Text}.cs");
            }
        }

        private static void CreateUSharpScript(string chosenFilePath)
        {
            // Packages\com.vrchat.udonsharp\Editor\Editors\UdonSharpBehaviourEditor.cs CreateUSharpScript() を参考に作成
        }
    }
}

テスト用スクリプトの生成はProjectWindowUtil.CreateScriptAssetFromTemplateFile
を使用しました。
UdonSharpの自動生成は「Packages\com.vrchat.udonsharp\Editor\Editors\UdonSharpBehaviourEditor.cs CreateUSharpScript()」
を参考にしました。

テスト用スクリプトの記述

ゲームのロジック部のスクリプトを記述します。
できるだけテスト用と製品用どちらでも動作するように、
基本的にUdonSharpに依存しないようにスクリプトを書いていきます。
もしテスト用と製品用でコードを分けたい場合は以下のように
PRODUCT defineを使用して分岐します。
PRODUCT defineは後述するように製品用スクリプトだけで定義されます。

#if !PRODUCT
    // テスト用コード
#else
    // 製品用コード
#endif

製品用スクリプトの生成

テスト用スクリプトから製品用スクリプトを生成します。
基本的にテスト用スクリプトをそのままコピーして製品用スクリプトにします。
足りない部分はUdonSharp専用の処理を追加します。

以下で詳しい手順を説明します。

Makeファイルの作成

変更したファイルのスクリプトだけを生成するため、
Makeファイルを作成するようにしました。
MakeファイルはPythonで作成しました。

import os
from glob import glob
import pathlib
import textwrap

PROJECT_NAME = 'MyProject'
mock_files_abs_path = glob(f'{PROJECT_NAME}/Assets/Mocks/*.cs')
mock_files = [
    pathlib.Path(file).relative_to(f'{PROJECT_NAME}')
    for file in mock_files_abs_path
]
script_files = []
for mock_file in mock_files:
    mock_file_base = os.path.basename(mock_file)
    script_file_base = mock_file_base.replace('Mock', '')
    script_files += [f'Assets/Scripts/{script_file_base}']

makefile = f"""
.PHONY: all clean
all: {' '.join(script_files)}
"""

for mock_file, script_file in zip(mock_files, script_files):
    makefile += textwrap.dedent(f"""
    {script_file}:{mock_file} make.py
    \tpython make.py $@ $<
    """)

with open(f'{PROJECT_NAME}/Makefile', 'w') as f:
    f.write(makefile)

上記を実行すると、以下のようなMakeファイルが作成されます。

.PHONY: all clean
all: Assets/Scripts/MyScript1.cs Assets/Scripts/MyScript2.cs

Assets/Scripts/MyScript1.cs:Assets\Mocks\MockMyScript1.cs make.py
    python make.py $@ $<

Assets/Scripts/MyScript2.cs:Assets\Mocks\MockMyScript2.cs make.py
    python make.py $@ $<

上記は、MockMyScript1.csからMyScript1.csを、
MockMyScript2.csからMyScript2.csをMakeする例です。

Makeコマンドの実行

作成したMakeファイルに対してMakeコマンドを実行すると以下の
「make.py」が実行されます。

import os
import re
from glob import glob
import sys

script_file_path = sys.argv[1]
mock_file_path = sys.argv[2]

def get_mock_classes():
    files = glob(f'{module_dir}/Assets/Mocks/*.cs')
    classes = {}
    for file in files:
        _class = os.path.splitext(os.path.basename(file))[0]
        target_class = _class.replace('Mock', '')
        classes[_class] = target_class
    return classes
mock_classes = get_mock_classes()

with open(mock_file_path, encoding='utf-8') as f:
    mock_strs = f.read()

mock_strs = '''
#define PRODUCT
using UdonSharp;
using VRC.SDKBase;
using VRC.Udon;
using VRC.Udon.Common.Interfaces;
using VRC.SDK3.Components;
''' + mock_strs

replace_items = [
    (rf'class {config["mock_prefix"]}(\w+)', r'class \g<1>'),
    ('MonoBehaviour', 'UdonSharpBehaviour'),
    (r'Debug.Assert\((.+)\)', r'if (!(\g<1>)) Debug.LogError($"Assert {name} (\g<1>) (at __FILE__)")'),
]
for replace_item in replace_items:
    mock_strs = mock_strs.split("\n")
    replace_item1 = replace_item[1].replace("__FILE__", script_file_path)
    for i,mock_str in enumerate(mock_strs):
        mock_strs[i] = re.sub(replace_item[0], replace_item1, mock_str)
    mock_strs = "\n".join(mock_strs)
for _class, target_class in mock_classes.items():
    mock_strs = mock_strs.replace(_class, target_class)

if not os.path.isfile(script_file_path):
    raise FileNotFoundError(script_file_path)
with open(script_file_path, 'w', encoding='utf-8') as f:
    f.write(mock_strs)

「make.py」はテスト用スクリプトをコピーして
製品用スクリプトを作成した後、以下のようなUdonSharp用の
変更を行っています。

PRODUCT defineの追加

先頭行に「#define PRODUCT」を追加します。

usingの追加

UdonSharpのusingを追加します。

Assertの変更

VRChatではAssertが使えないため、
「Debug.Assert」を「Debug.LogError」に変更しています。

シーンの生成

テスト用シーン「MockScene.unity」、製品用シーン「ProductScene.unity」
をEditor拡張で自動生成するようにしました。
GameObjectはテスト・製品用シーンで共通の構造とし、Prefab化することで
通化しました。
次からEditor拡張でシーンを生成する手順を説明します。

シーンを開く

まずプレハブを作るために、テスト用シーンを開きます。

string scenePath = "Assets/MockScene.unity";
var scene = EditorSceneManager.OpenScene(scenePath);

環境プレハブの作成

次に環境をまとめたプレハブ「Environment.prefab」を作成します。

static GameObject AddPrefab(string path, string name, out GameObject prefab, GameObject parent=null)
{
    prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
    Debug.Assert(prefab != null, $"{path} {name}");
    GameObject obj = PrefabUtility.InstantiatePrefab(prefab) as GameObject;
    //Undo.RegisterCreatedObjectUndo(obj, "Create GameObject");
    if (parent != null) obj.transform.SetParent(parent.transform, false);
    obj.name = name;
    return obj;
}

var Environment = AddEmptyGameObject("Environment");
AddPrefab("Packages/com.vrchat.worlds/Samples/UdonExampleScene/Prefabs/VRCWorld.prefab", "VRCWorld", out _, Environment);

ルートGameObjectの作成

次にテスト・製品用ルートGameObject「Mocks」・「Products」を作成します。

static GameObject AddEmptyGameObject(string name, GameObject parent=null)
{
    GameObject obj = new GameObject(name);
    //Undo.RegisterCreatedObjectUndo(obj, "Create GameObject");
    if (parent != null) obj.transform.SetParent(parent.transform, false);
    return obj;
}
var Mocks = AddEmptyGameObject("Mocks");
var Products = AddEmptyGameObject("Products");

共通プレハブの作成

次にテスト・製品用共通プレハブ「SceneMgr.prefab」を作成します。

var SceneMgr = AddEmptyGameObject("SceneMgr");

// SceneMgr以下にGameObjectの作成、マテリアルの設定、オーディオの設定などを行う

string SceneMgrPrefabPath = "Assets/Prefabs/SceneMgr.prefab";
PrefabUtility.SaveAsPrefabAssetAndConnect(SceneMgr, SceneMgrPrefabPath, InteractionMode.AutomatedAction);
DestroyImmediate(SceneMgr);

スクリプトのAddComponent

次にテスト・製品用のSceneMgr.prefabにそれぞれのスクリプトをAddComponentします

static MonoBehaviour AddScriptComponent(object root, string objPath, string path, string objType, string scriptPathPrefix, bool enabled = false)
{
    string _path = scriptPathPrefix + path;
    GameObject _root = null;
    if (root is GameObject) _root = root as GameObject;
    else _root = (root as MonoBehaviour).gameObject;
    Debug.Assert(root != null, $"{root.GetType().Name} {objPath} {_path}");
    GameObject obj;
    if (objPath == "")
    {
        obj = _root;
    }
    else
    {
        Transform transform = _root.transform.Find(objPath);
        Debug.Assert(transform != null, $"Transform Not Found {root.GetType().Name} {objPath} {_path}");
        obj = _root.transform.Find(objPath).gameObject;
    }
    Debug.Assert(obj != null, $"{objPath} {_path}");
    var script = AssetDatabase.LoadAssetAtPath<MonoScript>(_path);
    Debug.Assert(script != null, $"Script Not Found {objPath} {_path}");
    MonoBehaviour component;
    if (objType == "Mock")
    {
        //component = (MonoBehaviour)Undo.AddComponent(obj, script.GetClass());
        component = (MonoBehaviour)obj.AddComponent(script.GetClass());
        component.enabled = enabled;
    }
    else
    {
        //var udonSharpComponent = UdonSharpEditor.UdonSharpUndo.AddComponent(obj, script.GetClass());
        var udonSharpComponent = obj.AddUdonSharpComponent(script.GetClass());

        udonSharpComponent.enabled = enabled;
        component = udonSharpComponent;
    }
    return component;
}

foreach (var objType in new string[] { "Mock", "Product" })
{
    var root = (objType == "Mock") ? Mocks : Products;
    var scriptPefix = (objType == "Mock") ? "Mock" : "";
    var prefabPefix = (objType == "Mock") ? "Mock" : "Product";
    var dir = (objType == "Mock") ? "Mocks" : "Scripts";
    var scriptPathPrefix = $"Assets/{dir}/{scriptPefix}";

    var SceneMgr = AddPrefab("Assets/Prefabs/SceneMgr.prefab", "SceneMgr", out _, root);

    AddScriptComponent(SceneMgr, "", "SceneMgr.cs", objType, scriptPathPrefix, true);
    AddScriptComponent(SceneMgr, "MyObj", "MyObj.cs", objType, scriptPathPrefix);
}

UIの設定

次に製品用のGameObjectにボタンなどのUIを設定します。
詳しくはUdonSharpのボタンクリックイベントをUnityEditor拡張で登録するをご覧ください。

さらに製品用のGameObjectにVRCUiShapeなどUdon独自のUIを設定します。

var MyUi = SceneMgr.transform.Find('MyUi').gameObject;
MyUi..AddComponent<VRCUiShape>();
var MyAudio = SceneMgr.transform.Find('MyAudio').gameObject;
MyAudio.AddComponent<VRCSpatialAudioSource>();

プレハブ・シーンの保存

最後に作成したGameObjectをプレハブ・シーンとして保存します。

PrefabUtility.SaveAsPrefabAssetAndConnect(Environment, "Assets/Prefabs/Environment.prefab", InteractionMode.AutomatedAction);

PrefabUtility.SaveAsPrefabAssetAndConnect(Products, "Assets/Prefabs/Products.prefab", InteractionMode.AutomatedAction);

EditorSceneManager.SaveOpenScenes();

テスト用シーンのテスト

UnityのPlayを使いテスト用シーンのテストを行います。
不具合が見つかった場合はブレイクポイントを使い調査します。

製品用シーンのテスト

ClientSimを使い製品用シーンのテストを行います。
不具合が見つかった場合はInspectorからpublic変数の値を見て調査します。

ローカルVRChatアプリのテスト

VRChat SDKのLocal Testingを使いテストを行います。
この時Number of Clientsに2以上を指定して同期のテストを行います。
不具合が見つかった場合はDebug.LogErrorを使い調査します。

オンラインVRChatアプリのテスト

プライベートパブリッシュを行いテストを行います。
Quest対応を行う場合はここでテストを行います。

バージョン

  • Unity : 2019.4.31f1
  • VRChat SDK - Worlds : 3.2.3
  • UdonSharp : 1.1.9

Blenderで板ポリの木を作りUnityで表示する

はじめに

BlenderやUnityでは簡単に木を作ることができますが、
重いという問題点があります。そこで、木を板ポリにすることで
軽くする方法を説明します。
板ポリを十字に配置することで回り込みにもある程度対応します。

手順

Blenderで木を作る

  • Edit > Preference > Add-ons から以下を有効にする
    • Import Images as Planes
    • Sapling Tree Gen
    • Node Wrangler
  • Shit+A > Curve > Sapling Tree Gen から木を作る
  • Settings : Leaves > Show Leaves にチェック
  • ambientCGなどで、葉と幹のテクスチャをダウンロードする
  • Shift+A > Image > Images As Planes から葉のテクスチャを選択し葉のマテリアルを作成
    • できた葉のPlaneは削除する
  • leavesのマテリアルに葉のマテリアルを選択
  • UV Editingから葉のUVの形を葉に合わせる
  • treeを選択し幹のマテリアルを新規作成
  • Shading > Shift+Ctrl+T で幹のテクスチャを選択
  • Mapping > Rotation > Z に90°を指定

透過画像として出力する

  • CameraとLightを追加し位置を調整する
  • Render Properties > Film > Transparent にチェック
  • Render > Render Image から木のテクスチャ画像を出力

Blenderで板ポリの木を作る

  • Shift+A > Image > Images As Planes から木のテクスチャを読み込む
  • X軸に90°回転する
  • Edit Modeにし、Shit+Dで面を複製、Alt+N > Flipで面を反転し裏面を作る
  • Object ModeにしPlaneを複製してZ軸に90°回転し十字に配置する
  • Ctrl+JでPlane二つを結合する
  • File > Export > FBXからFBXで出力する

Unityで読み込み表示する

  • 右クリック > Create > Material を選択しマテリアルを作成する
  • ShaderをStandardにする
  • Rendering ModeをCutoutにし透過するようにする
  • Albedoに木のテクスチャを設定する
  • FBXをシーンに配置する
  • FBXのMaterialsを作成したマテリアルに設定する

参考サイト

バージョン

  • Unity : 2019.4.31f1
  • Blender : 3.2.1

UnityのRigidbodyでCharacterController風動作を作る

はじめに

UnityのCharacterControllerは便利ですが、
リアルな挙動ができるなどRigidbodyを使う方が自由度が高く
利点がある場合もあります。
そこでRigidbodyを使いCharacterControllerの機能をいくつか実装することで
両方の利点を生かす方法を考えます

方針

  • プレイヤーの入力で水平移動・ジャンプ・y軸回転ができるようにする
    • 水平移動は以下実装にすることでリアルな挙動を目指す
      • AddForce, ForceMode.Forceを使う
      • PID制御のP制御を行い目標速度に追従するようにする
    • ジャンプは以下実装にすることでリアルな挙動を目指す
      • AddForce, ForceMode.Impulseを使う
      • 接触面の角度を調べて地面かどうか判定する
      • 地面に接触していない時はジャンプしない
    • y軸回転はtransform.Rotateを使い、リアルな挙動は諦める
    • RigidbodyのFreeze RotationのX・Y・ZをONにすることで壁にぶつかっても回転しないようにする
  • 以下機能を実装することで坂を登れる(落ちない)ようにする
    • 接触面の角度を調べて坂かどうかを判定する
    • 坂に接触している時は重力を反転する

ソースコード

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// プレイヤーの入力
/// </summary>
public class PlayerInputs : MonoBehaviour
{
    public float HorizontalAxis = 0;
    public float VerticalAxis = 0;
    public float RotateAxis = 0;
    public bool JumpButton = false;

    private void Update()
    {
        JumpButton = Input.GetButton("Jump");
        HorizontalAxis = Input.GetAxis("Horizontal");
        VerticalAxis = Input.GetAxis("Vertical");
        RotateAxis = GetRotateAxis();
    }

    private float GetRotateAxis()
    {
        float ret = 0;
        if (Input.GetKey(KeyCode.E)) ret = 1;
        else if (Input.GetKey(KeyCode.Q)) ret = -1;
        return ret;
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// キャラクターに重力を与える
/// </summary>
public class GravityCharacterController : MonoBehaviour
{
    public float Gravity = -15; // 重力
    public bool IsEnabledGravity; // 重力有効かどうか
    private Rigidbody CharacterRigidbody;
    private void Start()
    {
        CharacterRigidbody = GetComponent<Rigidbody>();
    }
    void FixedUpdate()
    {
        if (IsEnabledGravity)
        {
            CharacterRigidbody.AddForce(0, Gravity, 0, ForceMode.Acceleration);
        }
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// プレイヤーの入力をもとにキャラクターを動かす
/// </summary>
public class PlayerCharacterController : MonoBehaviour
{
    public float JumpForce; // ジャンプ力
    public float MoveSpeed; // 水平移動の目標速度
    public float RotateSpeed; // 回転速度
    public float Kp; // P項係数

    public Vector3 Speed;

    PlayerInputs PlayerInputs;
    GroundChecker GroundChecker;
    Rigidbody CharacterRigidbody;

    Vector3 JumpForceVec = Vector3.zero;
    Vector3 MoveDirectionVec = Vector3.zero;
    Vector3 JumpDirectionVec = Vector3.zero;
    Vector3 MoveSpeedErr = Vector3.zero;

    private void Start()
    {
        PlayerInputs = GetComponent<PlayerInputs>();
        CharacterRigidbody = GetComponent<Rigidbody>();
        GroundChecker = GetComponent<GroundChecker>();
    }

    void FixedUpdate()
    {
        Move();
        Jump();
    }

    void Jump()
    {
        if (PlayerInputs.JumpButton && GroundChecker.IsGrounded)
        {
            JumpDirectionVec.x = MoveDirectionVec.x;
            JumpDirectionVec.z = MoveDirectionVec.z;
            JumpDirectionVec.y = 1.0f - (Mathf.Abs(JumpDirectionVec.x) + Mathf.Abs(JumpDirectionVec.z));
            JumpForceVec = JumpDirectionVec * JumpForce;
            CharacterRigidbody.AddForce(JumpForceVec, ForceMode.Impulse);
        }
    }

    void Move()
    {
        MoveDirectionVec = transform.forward * PlayerInputs.VerticalAxis + transform.right * PlayerInputs.HorizontalAxis;        
        Vector3 tgtMoveSpeed = MoveDirectionVec * MoveSpeed;
        MoveSpeedErr = tgtMoveSpeed - CharacterRigidbody.velocity;
        MoveSpeedErr.y = 0;
        Vector3 force = MoveSpeedErr * Kp;
        CharacterRigidbody.AddForce(force, ForceMode.Force);
        float rotateSpeed = RotateSpeed * PlayerInputs.RotateAxis;
        CharacterRigidbody.transform.Rotate(0, rotateSpeed * Time.fixedDeltaTime, 0);

        Speed = CharacterRigidbody.velocity;
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// キャラクターの当たり判定を行う
/// </summary>
public class CharacterCollider : MonoBehaviour
{
    SlopeChecker SlopeChecker;
    GroundChecker GroundChecker;
    private void Start()
    {
        SlopeChecker = GetComponent<SlopeChecker>();
        GroundChecker = GetComponent<GroundChecker>();
    }
    private void OnCollisionStay(Collision collision)
    {
        Vector3 collidedNormal = collision.contacts[0].normal;

        Vector3 v = Vector3.zero;
        v.x = collidedNormal.x;
        v.z = collidedNormal.z;
        v = v.normalized;
        float cosTheta = Vector3.Dot(collidedNormal, v);

        SlopeChecker.CosTheta = cosTheta;
        GroundChecker.CosTheta = cosTheta;
        SlopeChecker.IsCollided = true;
        GroundChecker.IsCollided = true;
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 坂のチェックを行う
/// </summary>
public class SlopeChecker : MonoBehaviour
{
    public float MinSlopeCos = 0.1f; // 坂と判定する最小のcos
    public float MaxSlopeCos = 0.9f; // 坂と判定する最大のcos
    public float GravityGain = 1e-3f; // 反転重力に掛ける係数
    public bool IsCollided; // 接触しているかどうか
    public float CosTheta; // 接触面の角度cos
    Rigidbody CharacterRigidbody;
    GravityCharacterController GravityCharacterController;
    Vector3 Force;

    private void Start()
    {
        CharacterRigidbody = GetComponent<Rigidbody>();
        GravityCharacterController = GetComponent<GravityCharacterController>();
    }

    void FixedUpdate()
    {
        if (!IsCollided)
        {
            GravityCharacterController.IsEnabledGravity = true;
            return;
        }
        IsCollided = false;
        
        float absCosTheta = Mathf.Abs(CosTheta);
        if ((absCosTheta >= MinSlopeCos) && (absCosTheta <= MaxSlopeCos))
        {
            Force.y = -GravityCharacterController.Gravity * GravityGain;
            CharacterRigidbody.AddForce(Force, ForceMode.Acceleration);
            GravityCharacterController.IsEnabledGravity = false;
        }
        else
        {
            GravityCharacterController.IsEnabledGravity = true;
        }
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 地面のチェックを行う
/// </summary>
public class GroundChecker : MonoBehaviour
{
    public float MaxGroundCos = 0.5f; // 地面と判定する最大のcos
    public bool IsCollided; // 接触しているかどうか
    public float CosTheta; // 接触面の角度cos
    public bool IsGrounded; // 地面に接触しているかどうか

    void FixedUpdate()
    {
        if (!IsCollided)
        {
            IsGrounded = false;
            return;
        }
        IsCollided = false;
        float absCosTheta = Mathf.Abs(CosTheta);
        if (absCosTheta <= MaxGroundCos) IsGrounded = true;
        else IsGrounded = false;
    }
}

Unityプロジェクトは以下にあります
https://github.com/hide00310/Unity_RigidbodyCharacterController

バージョン

Unity : 2019.4.31f1

UnityでRigidbodyを使いホーミング弾を作る

はじめに

Unityで3Dホーミング弾を作ります。
Rigidbodyを使いリアルな挙動を目指します。

方針

  • Rigidbody.AddForce、ForceMode.Forceを使う
  • ターゲットの方向に力を与える
  • PIDを使い速度が一定になるように制御を行う
  • 力は最大値以上にならないようにする

ソースコード

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

public class RigidbodyHoming : MonoBehaviour
{
    public Transform TgtTransform;
    public Rigidbody HomingRigidbody;

    public float Speed; // 追従速度
    public float MaxForce; // 最大の力
    public float Kp; // P項係数
    public float Ki; // I項係数
    public float Kd; // D項係数

    Vector3 SpeedErrInteg;
    Vector3 PresentSpeedErr;
    
    private void FixedUpdate()
    {
        float dt = Time.fixedDeltaTime;
        Vector3 tgtPos = TgtTransform.position;
        Vector3 diffDir = (tgtPos - transform.position).normalized; // ターゲットの方向
        Vector3 tgtSpeed = diffDir * Speed;
        Vector3 speedErr = tgtSpeed - HomingRigidbody.velocity;
        SpeedErrInteg += speedErr * dt;
        Vector3 prevSpeedErr = PresentSpeedErr;
        PresentSpeedErr = speedErr;
        Vector3 speedErrDiff = (PresentSpeedErr - prevSpeedErr) / dt;
        Vector3 force = Kp * speedErr + Ki * SpeedErrInteg + Kd * speedErrDiff; // PID制御
        float forceMagnitude = force.magnitude;
        if (forceMagnitude > MaxForce)
        {
            force = force / forceMagnitude * MaxForce; // 力を最大値にする
        }

        HomingRigidbody.AddForce(force, ForceMode.Force);
    }
}

Unityプロジェクトは以下にあります
https://github.com/hide00310/RigidbodyHoming/

バージョン

Unity : 2019.4.31f1

Unityで高度計を作る

はじめに

Unityで飛行機のコックピット風の高度計を作る方法を説明します

考え方

高度20mをステップ0、高度25mをステップ1のように
高度をステップで表します。
求めるのはキャンバス0地点から現在のステップまでのステップ
transStepです。

  • presentStep : 現在のステップ
  • presentPos : 現在のステップの高度
  • pos : 現在高度
  • transStep : キャンバス0地点から現在のステップまでのステップ
  • transPos : キャンバス0地点から現在のステップまでの高度
  • stepPos : 1ステップの高度

コード

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

public class Altimeter : MonoBehaviour
{
    public float StepPos;
    public float PosToCanvasUnit;
    public AltimeterNeedle[] Needles;
    public Rigidbody CharacterRigidbody;

    private void Update()
    {
        float pos = CharacterRigidbody.transform.position.y;
        for (int i = 0; i < Needles.Length; i++)
        {
            float transStep = GetTranslationStep(pos, StepPos, i - Needles.Length / 2);
            AltimeterNeedle needle = Needles[i];
            needle.transform.position = transform.position;
            needle.transform.Translate(0, transStep * PosToCanvasUnit, 0);
            string num = ((int)Mathf.Abs(pos / StepPos) + i).ToString("00");
            needle.Texts[0].text = num;
            needle.Texts[1].text = num;
        }
    }
    private float GetTranslationStep(float pos, float stepPos, int presentStep)
    {
        float presentPos = stepPos * (int)(pos / stepPos);
        float diffPos = presentPos - pos;
        float transPos = diffPos + stepPos * presentStep;
        return transPos / stepPos;
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class AltimeterNeedle : MonoBehaviour
{
    public Text[] Texts;
}

Unityプロジェクトは以下にあります。
https://github.com/hide00310/Unity_Altimeter