はじめに
本記事はUnity #3 Advent Calendar 201912月14日の記事です。
みなさんはステージ上で、チームによって動的にテクスチャを変えたいと思ったことはありませんか?
私はありました。
ということで、本記事では、チームによってステージのテクスチャを変えるために私が使用した方法を紹介しようと思います。(強引な導入)
方法は簡単で、通常のレンダリングに利用されるテクスチャやパラメータを3セット用意し、選択マップで3セット分のテクスチャやパラメータをブレンドするという力業なものです。
なお、本記事で説明するシェーダーは、昔からあるUnity built-in render pipelineを対象としたもので、Scriptable Render Pipeline(SRP)は対象としていません。
(SRPで使えるShader Graphを使えば本記事の内容はコードを書かずに簡単に実現できるかもしれないので、機会があったらShader Graphバージョンの記事を書くかもしれない)
確認環境
- Unity2019.2.12f1
- Windows10
概要
以下の画像の上段のような3つのテクスチャと、それぞれをRGBチャンネルに対応付けた下段のような選択マップを用意して……
選択マップの赤部分に左のテクスチャが、緑部分に真ん中のテクスチャが、青部分に右のテクスチャが表示されているのが分かります。
選択マップはワールド座標におけるxz平面に対応しており、位置によってテクスチャを切り替えています。この性質からこんなことができます。
動かしているボックスのテクスチャが位置によって変わっているのが分かると思います。
この記事ではこの動作を実現するシェーダーの実装方法について説明します。
方針
Unityでは主に以下の種類のシェーダーがあります。
より詳細な説明はNEAREAL「Unity の Shader の種類:超速Unityシェーダ入門(1)」が参考になります。
- Surface Shader: 簡単なコードを書くだけで内部で複雑なシェーダーを生成してくれるシェーダー
- Vertex, Fragment Shader: 独自にカスタマイズしたシェーダーを作れる。Surface Shaderでは実現できないような処理が書きたいときなどに使う
- Compute Shader: GPUで計算を行うためのシェーダー
本記事ではそのお手軽さから、Surface Shaderを使用します。
実装
基本的なSurface Shader
まず、基本的な機能を備えた物理ベースシェーダーの実装を確認します。
StandardシェーダーでRendering Mode
がOpaque
に設定されている場合に相当するものです。
Surface Shaderを使用すると簡単に書くことができます。
Shader "Custom/StandardOpaque" {
Properties {
_Color("Color", Color) = (1,1,1,1)
_MainTex("Albedo (RGB)", 2D) = "white" {}
_Glossiness("Smoothness", Range(0,1)) = 0.5
[Gamma] _Metallic("Metallic Scale", Range(0,1)) = 0.0
[NoScaleOffset]_MetallicGlossMap("Metallic", 2D) = "white" {}
_BumpScale("Normal Scale", Float) = 1.0
[NoScaleOffset][Normal] _BumpMap("Normal Map", 2D) = "bump" {}
[HDR] _EmissionColor("Emittion Color", Color) = (0, 0, 0, 0)
[NoScaleOffset] _EmissionTex("Emission", 2D) = "white" {}
_OcclusionStrength("Occlusion Strength", Range(0.0, 1.0)) = 1.0
[NoScaleOffset] _OcclusionMap("Occlusion", 2D) = "white" {}
}
SubShader {
Tags { "Queue"="Geometry" "RenderType"="Opaque" }
LOD 200
CGPROGRAM
#pragma surface surf Standard fullforwardshadows
#pragma target 3.0
fixed4 _Color;
sampler2D _MainTex;
half _BumpScale;
sampler2D _BumpMap;
fixed4 _EmissionColor;
sampler2D _EmissionTex;
half _OcclusionStrength;
sampler2D _OcclusionMap;
half _Glossiness;
half _Metallic;
sampler2D _MetallicGlossMap;
struct Input {
float2 uv_MainTex;
};
void surf (Input IN, inout SurfaceOutputStandard o) {
// Albedo comes from a texture tinted by color
fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
o.Albedo = c.rgb;
// Bump Map
fixed4 n = tex2D(_BumpMap, IN.uv_MainTex);
o.Normal = UnpackScaleNormal(n, _BumpScale);
// Emission
fixed4 e = tex2D(_EmissionTex, IN.uv_MainTex);
o.Emission = _EmissionColor * e;
// Occlusion (合ってるかわからない)
fixed4 oc = tex2D(_OcclusionMap, IN.uv_MainTex);
o.Occlusion = oc * _OcclusionStrength;
// Metallic and smoothness come from slider variables
fixed4 m = tex2D(_MetallicGlossMap, IN.uv_MainTex);
o.Metallic = m * _Metallic;
o.Smoothness = _Glossiness;
o.Alpha = c.a;
}
ENDCG
}
FallBack "Standard"
}
本記事の説明で必要な部分を中心に説明していきます。
本記事で説明していないSurface Shaderのより詳しい説明は、LIGHT11「【Unity】Surface Shaderの基本を総まとめ!難しい計算はUnity任せでサクッとシェーダ作成」や@IT「Unityで始めるシェーダー入門」が参考になります。
まず、Properties
ブロックで、Unityのインスペクタで設定できるプロパティを定義しています。
続いて、SubShader
ブロックではシェーダー本体のコードや設定が書かれています。
以下のコードは、どのようなSurface Shaderを生成するか設定しています。今回は物理ベースですべてのライトシャドウをサポートするよう設定しています。加えて、surf()
を実際の処理時に呼びされるシェーダー関数として設定しています。
#pragma surface surf Standard fullforwardshadows
その下で、シェーダープログラム内で使用する変数を定義しています。
Property
ブロックで定義した変数はここで定義することで、プログラム内で使用可能になります。
次にInput
構造体ですが、これはシェーダー関数(今回はsurf()
)の入力として使用されるものです。
処理対象のピクセルなどの情報が入っています。あらかじめ決められたものを定義することで、適切な値が格納されます。ここで定義しているuv_MainTex
には、メインテクスチャ上のUV座標が格納されます。
そして、このシェーダーの本体であるsurf()
です。上で説明した#pragma surface surf ...
の部分で指定されているものです。
説明したInput
型の変数を入力として受け取り、SurfaceOutputStandard
型のとして受け取った変数へ出力する計算結果を格納します。
surf()
内では出力変数の各値について、計算して格納しています。それぞれの詳細は本記事では触れないので、参考サイトなどを参照してください。
三種類のテクスチャを扱う
本題に入ります。
まずは、単純に前の章で使用したProperty
ブロックの項目をそれぞれ3つ分用意します。
ここで、全ての項目を3つづつ用意すると、テクスチャプロパティ数がDirectX11における上限である16個を超えてしまいます。
そのため、法線マップ、金属光沢マップ、オクルージョンマップは共通のものを使用するようにしています。
また、三種類のテクスチャをどのように反映するかを選択するための、選択テクスチャ_selectionMap
と選択に用いるワールド空間の領域_SelectionArea
を加え、プロパティは以下のようになります。_selectionMap
と_SelectionArea
については下で説明します。
Properties{
[NoScaleOffset] _SelectionMap("Selection Map", 2D) = "red" {}
_SelectionArea("Selection Area (MinX, MinZ, MaxX, MaxZ)", Vector) = (0, 0, 100, 100)
_Cutoff("Alpha Cutoff (Valid In Cutout Mode)", Range(0,1)) = 0.5
_Color("[1] Color", Color) = (1,1,1,1)
_MainTex("[1] Albedo (RGB)", 2D) = "white" {}
_Glossiness("[1] Smoothness", Range(0,1)) = 0.5
[Gamma] _Metallic("[1] Metallic Scale", Range(0,1)) = 0.0
[NoScaleOffset]_MetallicGlossMap("[1] Metallic", 2D) = "white" {}
_BumpScale("[1] Normal Scale", Float) = 1.0
[NoScaleOffset][Normal] _BumpMap("[1] Normal Map", 2D) = "bump" {}
[HDR] _EmissionColor("[1] Emittion Color", Color) = (0, 0, 0, 0)
[NoScaleOffset] _EmissionTex("[1] Emission", 2D) = "white" {}
_OcclusionStrength("[1] Occlusion Strength", Range(0.0, 1.0)) = 1.0
[NoScaleOffset] _OcclusionMap("[1] Occlusion", 2D) = "white" {}
_Color2("[2] Color", Color) = (1,1,1,1)
[NoScaleOffset] _MainTex2("[2] Albedo (RGB)", 2D) = "white" {}
_Glossiness2("[2] Smoothness", Range(0,1)) = 0.5
[Gamma] _Metallic2("[2] Metallic Scale", Range(0,1)) = 0.0
[HDR] _EmissionColor2("[2] Emittion Color", Color) = (0, 0, 0, 0)
[NoScaleOffset] _EmissionTex2("[2] Emission", 2D) = "white" {}
_OcclusionStrength2("[2] Occlusion Strength", Range(0.0, 1.0)) = 1.0
_Color3("[3] Color", Color) = (1,1,1,1)
[NoScaleOffset] _MainTex3("[3] Albedo (RGB)", 2D) = "white" {}
_Glossiness3("[3] Smoothness", Range(0,1)) = 0.5
[Gamma] _Metallic3("[3] Metallic Scale", Range(0,1)) = 0.0
[HDR] _EmissionColor3("[3] Emittion Color", Color) = (0, 0, 0, 0)
[NoScaleOffset] _EmissionTex3("[3] Emission", 2D) = "white" {}
_OcclusionStrength3("[3] Occlusion Strength", Range(0.0, 1.0)) = 1.0
}
インスペクタに表示するデータを構造体でまとめることはできないので、単純にコピペしたのですが、もっといい方法ないですかね。
ワールド座標の選択テクスチャUV座標への変換
選択テクスチャのどの部分を参照するのかを決めるために、表示しようとしている点のワールド座標を選択テクスチャ上の座標に変換します。
まず、選択テクスチャがワールド座標においてカバーしている範囲の情報が必要になります。その情報の指定を_SelectionArea
で行うことができます。
_SelectionArea
は、XZ平面で選択テクスチャがカバーする矩形をx座標とz座標の最大値最小値として保持します。
_SelectionArea("Selection Area (MinX, MinZ, MaxX, MaxZ)", Vector) = (0, 0, 100, 100)
次に、計算対象となる点のワールド座標が必要です。
これは入力構造体の定義に以下の行を追加することで使用可能です。
struct Input {
float2 uv_MainTex;
float3 worldPos; // これ
};
以上の二つを使用して、選択マップにおける座標は以下のコードで計算できます。
float2 calculate_selection_map_uv(float3 world_pos, float4 selection_area){
float2 uv;
uv.x = (world_pos.x - selection_area.x) / (selection_area.z - selection_area.x);
uv.y = (world_pos.z - selection_area.y) / (selection_area.w - selection_area.y);
return uv;
}
戻り値は、選択マップ上の位置を表す、それぞれの要素が0から1であるfloat2型です。
選択マップの反映
上で求めた参照位置における、選択マップの色情報を以下のコードで取得します。αチャンネルは使用しないので、fixed3
型で受け取ることで無視します。
float2 selection_map_uv = calculate_selection_map_uv(IN.worldPos, _SelectionArea);
fixed3 sm = tex2D(_SelectionMap, selection_map_uv);
sm
は各チャンネルがそれぞれ赤、緑、青を表す、値域が0から1の色情報を格納した三次元ベクトルです。
3つのテクスチャをsm
の各チャンネルの値で重み付けして加算し、得られた値を最終出力とします。
この動作を実現するために、surf_core()
を以下のように定義します。
surf_core()
は各チャンネルでの出力値を計算し反映する関数です。
void surf_core(Input IN, MaterialSetting ms, fixed ratio, inout SurfaceOutputStandard o) {
// Albedo comes from a texture tinted by color
fixed4 c = tex2D(ms.MainTex, IN.uv_MainTex) * ms.Color;
o.Albedo += c.rgb * ratio;
// Bump Map
fixed4 n = tex2D(ms.BumpMap, IN.uv_MainTex);
o.Normal += UnpackScaleNormal(n, ms.BumpScale) * ratio;
// Emission
fixed4 e = tex2D(ms.EmissionTex, IN.uv_MainTex);
o.Emission += ms.EmissionColor * e * ratio;
// Occlusion (合ってるかわからない)
fixed4 oc = tex2D(ms.OcclusionMap, IN.uv_MainTex);
o.Occlusion += oc * ms.OcclusionStrength * ratio;
// Metallic and smoothness come from slider variables
fixed4 m = tex2D(ms.MetallicGlossMap, IN.uv_MainTex);
o.Metallic += m * ms.Metallic * ratio;
o.Smoothness += ms.Glossiness * ratio;
o.Alpha += c.a * ratio;
}
基本的には[基本的なSurface Shader](#基本的なSurface Shader)に載せたコードのsurf()
関数と同じです。
違いは、比率ratio
を受け取り、各要素にratio
を掛けた値を加算するようになっている点です。
引数で使用されているMaterialSetting
構造体は、入力変数をまとめたものです。
struct MaterialSetting {
fixed4 Color;
sampler2D MainTex;
half BumpScale;
sampler2D BumpMap;
fixed4 EmissionColor;
sampler2D EmissionTex;
half OcclusionStrength;
sampler2D OcclusionMap;
half Glossiness;
half Metallic;
sampler2D MetallicGlossMap;
};
値を設定するのではなく加算していくので、一番初めに出力構造体の要素を全て初期化する必要があります。
このsurf_core()
をsurf()
ないで、_SelectionMap
の3チャンネル分呼び出します。
これで、最終的には_SelectionMap
の各チャンネルの値に応じて、3つの設定によるしゅつりょkぐあブレンドされた出力が得られます。
全体の実装
ソースコードのファイルは以下の二つになっています。
-
TripleBlendStandardOpaque.shader
: StandardShanderの設定とTripleBlendStandardCore.cginc
のインクルード -
TripleBlendStandardCore.cginc
: 選択マップによるテクスチャの加算
cginc
ファイルはほかのシェーダープログラムからC言語のように#include
することができるファイルで、同じ処理や変数のコード共通化などに活用できます。
Shader "Triple/Standard-Opaque"{
Properties{
[NoScaleOffset] _SelectionMap("Selection Map", 2D) = "red" {}
_SelectionArea("Selection Area (MinX, MinZ, MaxX, MaxZ)", Vector) = (0, 0, 100, 100)
_Cutoff("Alpha Cutoff (Valid In Cutout Mode)", Range(0,1)) = 0.5
_Color("[1] Color", Color) = (1,1,1,1)
_MainTex("[1] Albedo (RGB)", 2D) = "white" {}
_Glossiness("[1] Smoothness", Range(0,1)) = 0.5
[Gamma] _Metallic("[1] Metallic Scale", Range(0,1)) = 0.0
[NoScaleOffset]_MetallicGlossMap("[1] Metallic", 2D) = "white" {}
_BumpScale("[1] Normal Scale", Float) = 1.0
[NoScaleOffset][Normal] _BumpMap("[1] Normal Map", 2D) = "bump" {}
[HDR] _EmissionColor("[1] Emittion Color", Color) = (0, 0, 0, 0)
[NoScaleOffset] _EmissionTex("[1] Emission", 2D) = "white" {}
_OcclusionStrength("[1] Occlusion Strength", Range(0.0, 1.0)) = 1.0
[NoScaleOffset] _OcclusionMap("[1] Occlusion", 2D) = "white" {}
_Color2("[2] Color", Color) = (1,1,1,1)
[NoScaleOffset] _MainTex2("[2] Albedo (RGB)", 2D) = "white" {}
_Glossiness2("[2] Smoothness", Range(0,1)) = 0.5
[Gamma] _Metallic2("[2] Metallic Scale", Range(0,1)) = 0.0
[HDR] _EmissionColor2("[2] Emittion Color", Color) = (0, 0, 0, 0)
[NoScaleOffset] _EmissionTex2("[2] Emission", 2D) = "white" {}
_OcclusionStrength2("[2] Occlusion Strength", Range(0.0, 1.0)) = 1.0
_Color3("[3] Color", Color) = (1,1,1,1)
[NoScaleOffset] _MainTex3("[3] Albedo (RGB)", 2D) = "white" {}
_Glossiness3("[3] Smoothness", Range(0,1)) = 0.5
[Gamma] _Metallic3("[3] Metallic Scale", Range(0,1)) = 0.0
[HDR] _EmissionColor3("[3] Emittion Color", Color) = (0, 0, 0, 0)
[NoScaleOffset] _EmissionTex3("[3] Emission", 2D) = "white" {}
_OcclusionStrength3("[3] Occlusion Strength", Range(0.0, 1.0)) = 1.0
}
CGINCLUDE
#include "TripleBlendStandardCore.cginc"
ENDCG
SubShader{
Tags { "Queue" = "Geometry" "RenderType" = "Opaque" }
LOD 200
CGPROGRAM
#pragma surface surf Standard fullforwardshadows
#pragma target 3.0
ENDCG
}
Fallback "Standard"
}
#ifndef TRIPLE_STANDARD_CORE_CGINC_INCLUDED
#define TRIPLE_STANDARD_CORE_CGINC_INCLUDED
sampler2D _SelectionMap;
float4 _SelectionArea;
fixed4 _Color;
sampler2D _MainTex;
half _BumpScale;
sampler2D _BumpMap;
fixed4 _EmissionColor;
sampler2D _EmissionTex;
half _OcclusionStrength;
sampler2D _OcclusionMap;
half _Glossiness;
half _Metallic;
sampler2D _MetallicGlossMap;
fixed4 _Color2;
sampler2D _MainTex2;
fixed4 _EmissionColor2;
sampler2D _EmissionTex2;
half _OcclusionStrength2;
half _Glossiness2;
half _Metallic2;
fixed4 _Color3;
sampler2D _MainTex3;
fixed4 _EmissionColor3;
sampler2D _EmissionTex3;
half _OcclusionStrength3;
half _Glossiness3;
half _Metallic3;
struct Input {
float2 uv_MainTex;
float3 worldPos;
};
struct MaterialSetting {
fixed4 Color;
sampler2D MainTex;
half BumpScale;
sampler2D BumpMap;
fixed4 EmissionColor;
sampler2D EmissionTex;
half OcclusionStrength;
sampler2D OcclusionMap;
half Glossiness;
half Metallic;
sampler2D MetallicGlossMap;
};
float2 calculate_selection_map_uv(float3 world_pos, float4 selection_area){
float2 uv;
uv.x = (world_pos.x - selection_area.x) / (selection_area.z - selection_area.x);
uv.y = (world_pos.z - selection_area.y) / (selection_area.w - selection_area.y);
return uv;
}
void surf_core(Input IN, MaterialSetting ms, fixed ratio, inout SurfaceOutputStandard o) {
// Albedo comes from a texture tinted by color
fixed4 c = tex2D(ms.MainTex, IN.uv_MainTex) * ms.Color;
o.Albedo += c.rgb * ratio;
// Bump Map
fixed4 n = tex2D(ms.BumpMap, IN.uv_MainTex);
o.Normal += UnpackScaleNormal(n, ms.BumpScale) * ratio;
// Emission
fixed4 e = tex2D(ms.EmissionTex, IN.uv_MainTex);
o.Emission += ms.EmissionColor * e * ratio;
// Occlusion (合ってるかわからない)
fixed4 oc = tex2D(ms.OcclusionMap, IN.uv_MainTex);
o.Occlusion += oc * ms.OcclusionStrength * ratio;
// Metallic and smoothness come from slider variables
fixed4 m = tex2D(ms.MetallicGlossMap, IN.uv_MainTex);
o.Metallic += m * ms.Metallic * ratio;
o.Smoothness += ms.Glossiness * ratio;
o.Alpha += c.a * ratio;
}
void surf(Input IN, inout SurfaceOutputStandard o) {
float2 selection_map_uv = calculate_selection_map_uv(IN.worldPos, _SelectionArea);
fixed3 sm = tex2D(_SelectionMap, selection_map_uv);
o.Albedo = 0;
o.Normal = 0;
o.Emission = 0;
o.Occlusion = 0;
o.Metallic = 0;
o.Smoothness = 0;
o.Alpha = 0;
MaterialSetting ms;
ms.Color = _Color;
ms.MainTex = _MainTex;
ms.BumpScale = _BumpScale;
ms.BumpMap = _BumpMap;
ms.EmissionColor = _EmissionColor;
ms.EmissionTex = _EmissionTex;
ms.OcclusionStrength = _OcclusionStrength;
ms.OcclusionMap = _OcclusionMap;
ms.Glossiness = _Glossiness;
ms.Metallic = _Metallic;
ms.MetallicGlossMap = _MetallicGlossMap;
surf_core(IN, ms, sm.r, o);
ms.Color = _Color2;
ms.MainTex = _MainTex2;
ms.BumpScale = _BumpScale;
ms.BumpMap = _BumpMap;
ms.EmissionColor = _EmissionColor2;
ms.EmissionTex = _EmissionTex2;
ms.OcclusionStrength = _OcclusionStrength2;
ms.OcclusionMap = _OcclusionMap;
ms.Glossiness = _Glossiness2;
ms.Metallic = _Metallic2;
ms.MetallicGlossMap = _MetallicGlossMap;
surf_core(IN, ms, sm.g, o);
ms.Color = _Color3;
ms.MainTex = _MainTex3;
ms.BumpScale = _BumpScale;
ms.BumpMap = _BumpMap;
ms.EmissionColor = _EmissionColor3;
ms.EmissionTex = _EmissionTex3;
ms.OcclusionStrength = _OcclusionStrength3;
ms.OcclusionMap = _OcclusionMap;
ms.Glossiness = _Glossiness3;
ms.Metallic = _Metallic3;
ms.MetallicGlossMap = _MetallicGlossMap;
surf_core(IN, ms, sm.b, o);
}
#endif // TRIPLE_STANDARD_CORE_CGINC_INCLUDED
注意しないといけないのは、使用するテクスチャ枚数が2倍以上になっているため使用するビデオメモリの量も2倍以上になる点です。
使用方法
すると、初めに見せた画像のような状態になります。
応用例
現在制作しているゲームで使用している、本記事で書いた方法の使用例を載せておきます。
現在作成しているゲームでは、ステージ内にタワーが配置されており、タワーは「影」「光」「中立」チームのいずれかが保持しています。
タワーの周辺はそのチームの領域になり、それによってステージの見た目が変化する仕組みになっています。
まずは、【Unity】コンピュートシェーダーを使ってボロノイ図のビットマップを生成するの記事で説明した方法で、チーム領域ごとの選択マップを生成します。緑が光、青が影、赤が中立チームです。それを使用して、本記事で説明した方法でステージ全体をチームごとに色分けします。
タワーのチームが変わると、それに応じてリアルタイムに周辺の地形の色が変化します。
その他
ここで説明したソースコードは以下のリポジトリに置いてあります。
GitHub「unity-triple-blend-shader」
今回説明したシェーダーは、StandardシェーダーにおけるRendering Mode
はOpaque
に設定されている場合に当てはまるものですが、ほかの3つのモードに対応するシェーダーもリポジトリに置いてあります。
また、選択マップを実行時に設定するためのスクリプトも用意してあります。
参考文献
- LIGHT11「【Unity】Surface Shaderの基本を総まとめ!難しい計算はUnity任せでサクッとシェーダ作成」: Surface Shaderの書き方について分かりやすい説明が載っています
- NEAREAL「Unity の Shader の種類:超速Unityシェーダ入門(1)」: Unityのシェーダーの種類について簡単な説明が載っています
- Unitu Documentation「サーフェスシェーダーの記述」: サーフェスシェーダーについての公式リファレンスです
- UnityShader 入門: Standard Shaderについての説明が載っています
- @IT「Unityで始めるシェーダー入門」: Unityのシェーダーについて一通り載っています