
プロジェクトセカイ 3周年グラフィックスアップデート解説 (目の透過表現)
はじめに
この記事は Colorful Palette アドベントカレンダー 12/2 の記事です。
株式会社Colorful Paletteにて、クライアントエンジニア兼グラフィックスエンジニアをしている とめさん です。
今回は、
「プロジェクトセカイ カラフルステージ! feat. 初音ミク」(以下、「プロセカ」)に追加された 目の透過表現の技術について紹介します。
目の透過表現
プロセカの3周年アップデートにて、目の透過表現が追加されました。

目の透過について
キャラクターの目を透過させることで、キャラクターの繊細な感情表現が可能となります。


開発環境について
プロセカの開発には、ゲームエンジン Unity を採用しています。
描画まわりには Universal RP を採用しています。
4つの手法
目の透過を実装するにあたって、合計で4種類の手法を試しました。
手法1. 目のZTest Always にしつつ、髪でマスクする
手法2. 目のステンシルを書き込んでおき、髪を描画した際にステンシルを1増やす
手法3. 目の頂点を手前にずらして表示する
手法4. キャラに対応する桁のステンシルを使ってマスクする (採用)
手法1, 2, 3 では描画の破綻が起きたため、手法4を採用しています。
今回の記事では、手法1, 2, 3 の実装の概要と問題点を紹介するとともに、手法4の実装を解説します。
キャラクターの目の描画の実装について
目の透過の手法を紹介する前に、プロセカの目の描画はどのような実装になっているかを軽く紹介します。
キャラクターの目の描画処理は、2つのPassで構成されています。
パス1 : 通常描画パス (不透明)
パス2 : 目の透過の描画パス (半透明)
// パス1. 通常描画を行うパス
Pass
{
Name "Base"
Tags
{
"LightMode" = "SRPDefaultUnlit"
}
...
}
// パス2. 目の透過用の描画パス
Pass
{
Name "Eyelash"
Tags
{
"LightMode" = "SampleEyelash"
}
Cull Back
ZTest Always
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
...
}
これらのパスは、Universal RPのDrawObjectsをカスタマイズした、独自のRendererFeatureによって実行されます。
手法1. 目のZTest Always にしつつ、髪でマスクする
1つめの手法は、目の透過を描画する際に、ZTest Alwaysにするというものになります。
この手法は、まつ毛だけを透過させる場合に正常に表示されます。

// パス1. 通常描画を行うパス
Pass
{
Name "Base"
Tags
{
"LightMode" = "SRPDefaultUnlit"
}
Blend One Zero
ZTest LEqual
...
}
// パス2. 目の透過用の描画パス
Pass
{
Name "Eyelash"
Tags
{
"LightMode" = "SampleEyelash"
}
Blend SrcAlpha OneMinusSrcAlpha
ZTest Always
...
}
手法1の問題
この手法だと、眼球を透過させる際に描画が破綻します。
通常、瞳はまぶたの裏に隠れており、Zテストによって隠れています。

しかし、Zテストを常に成功させてしまうと、まぶたの裏に隠れるはずの瞳も透過されて表示されてしまう、という破綻が起きます。
手法2. 目のステンシルを書き込んでおき、髪を描画した際にステンシルを1増やす
2つ目の手法は、目のステンシルを書き込んでおき、髪を描画した際にステンシルを1増やす、というものになります。
目の描画時、ステンシルを書き込む ( 瞳:10 まつ毛:12)
髪を描画時、ステンシルを1ふやす (瞳 : 10 →11, まつ毛: 12 → 13)
目の透過の描画時、ステンシルでマスクをかける. (瞳 : 11,まつ毛 : 13)
目の透過の描画は ZTest Always で実行します。


手法2の問題
キャラの目の手前に別のキャラの髪が重なったとき、
別のキャラの髪の毛の手前に目が透けて表示される、という描画の破綻が発生します。

また、髪どうしが重なった際、ステンシルを1増やす処理が2回走るため、想定と異なるステンシル値が画面に書き込まれることになります。
その結果、目の透過の描画が一部欠けるという破綻が発生します。

手法3. 目の頂点を手前にずらして表示する
手法3は、目の透過を描画する際に頂点をカメラ方向にずらすという手法になります。
目の透過の描画は ZTest LEqual で実行します。
// 頂点シェーダー
v2f Vert (appdata v)
{
v2f o = (v2f)0;
v.vertex = TransformObjectToWorld(v.vertex.xyz);
// 目の透過用に頂点座標をカメラの方向へずらす
o.vertex.z = CalcEyelashPositionZ(o.vertex.z)
...
// 省略
...
return o;
}
/**
* \brief 目の透過用に頂点座標をカメラの方向へずらす
* \param clipZ クリップ空間 Z座標 (深度)
*/
float CalcEyelashPositionZ(float clipZ)
{
// クリップ空間の深度(非線形)を線形な深度に変換する (Universal RPのLinearEyeDepth)
// Metal系 : [1, 0] -> [n, f] (UNITY_REVERSED_Z = 1)
// OpenGL系 : [0, 1] -> [n, f] (UNITY_REVERSED_Z = 0)
float realZ = 1.0 / (_ZBufferParams.z * clipZ + _ZBufferParams.w);
// カメラ方向に近づける (NOTE: 小さすぎると実機にて目が透けないことがある)
realZ -= 0.4 + _EyelashPositionOffset;
// 線形な深度をクリップ空間の深度に戻す (Universal RPのLinearEyeDepthの逆変換)
clipZ = (1.0 - _ZBufferParams.w * realZ) / (_ZBufferParams.z * realZ);
return saturate(clipZ);
}
これはMetalやVulkan系のグラフィックスAPIでは正常に透過が表示されますが、OpenGLES系の端末では正常に表示されません。
手法3の問題
手法3は、Android端末 (OpenGLES系のグラフィックスAPI)だと、奥にいるキャラクターの目の透過が手前のキャラクターの手前に表示されてしまうという問題が発生しました。
OpenGLES系のグラフィックスAPIは、カメラに近い部分の深度バッファの精度が低いため、このような問題が発生します。
手法4. キャラに対応する桁のステンシルを使ってマスクする
手法4は、キャラに対応する桁のステンシルバッファを使って、キャラの透過をマスクする手法になります。
プロセカの3周年アップデートでは、手法4を採用しています。
1) 別のキャラが重なったときの描画の破綻を無くす
STEP1. 目を描画したとき、キャラ番号に対応する桁のステンシルバッファのビット列を1にする

キャラクター2のステンシル実装例を以下に示します。
Stencil
{
Ref 255 // ステンシル参照値 : 255 (すべてのビット列が1)
WriteMask 4 // ステンシル書き込み時、ビット4 (0b00000100) でマスクをかける
Comp Always // ステンシルテスト常に成功させる
Pass Replace // ステンシル参照値(255)をステンシルバッファに書き込む
}
ステンシル参照値 ( = 255) をステンシルバッファに書き込みますが、WriteMask = 4 (0b00000100) を指定しているため、3桁目のビット列のみが1に置き換わります。
STEP2. 髪を描画したとき、キャラ番号に対応する桁以外のステンシルバッファのビット列を0にします。

キャラクター2のステンシル実装例を以下に示します。
Stencil
{
Ref 0
WriteMask 251 // ステンシル書き込み時、ビット0b11111011) でマスクをかける
Comp Always // ステンシルテスト常に成功させる
Pass Replace // ステンシル参照値(0)をステンシルバッファに書き込む
}
ステンシル参照値 ( = 0)をステンシルバッファに書き込みますが、
WriteMask = 251 (0b11111011) を指定しているため、3桁目を除いたすべての桁が0に置き換わります。
STEP3. 目の透過描画時、キャラ番号に対応する桁のステンシルバッファのビット列が1だったら透過を描画させます。
目の透過の描画は ZTest Always で実行します。

Stencil
{
Ref 4 // ステンシル参照値 (0b00000100)
Comp Equal // ステンシルバッファの値がステンシル参照値(4)と一致したらステンシルテスト成功
Pass Zero // ステンシル成功時をステンシルバッファを0にする
}
以上で、複数のキャラクターが重なったときに、目の描画順がおかしくなってしまう問題は解決できます。
2) 瞳とまつ毛の描画順のコントロール
瞳やまつ毛はZTest Alwaysで描画しているため、まつ毛と瞳が重なって表示されてしまうという描画の破綻が生じます。

これを解決するため、まつ毛の描画時にステンシルにリセットをかけます。
具体的な処理は以下になります。
ステンシルでマスクをかけつつ、まつ毛を描画する。
まつ毛を描画した領域のステンシルを0にリセット
ステンシルでマスクをかけつつ、瞳を描画する

このステンシルテストを実装するための例を以下に示します。
Stencil
{
Ref 1 // 参照値
ReadMask 1 // ステンシル取得時、ビット1でマスクをかける
WriteMask 1 // ステンシル書き込み時、ビット1でマスクをかける
Comp Equal // ステンシルバッファの値が参照値と等しい場合、ステンシルテスト成功
Pass Zero // ステンシルテスト成功時、ステンシル値を0にする (WriteMaskでマスクをかけたビットのみ書き変わる)
}
この対応を行うことで、まつ毛の表示領域には瞳が描画されなくなります。

手法4の実装解説
1. シェーダー実装
実際のシェーダーの記述を以下に示します。
目に関係ない部分に関しては省略しています。
Shader "Sample/Character"
{
Properties
{
// 目の通常描画時のステンシル
[Title(Stencil)]
_Stencil ("Stencil ID [0;255]", Int) = 1
_ReadMask ("ReadMask [0;255]", Int) = 255
_WriteMask ("WriteMask [0;255]", Int) = 255
[Enum(UnityEngine.Rendering.CompareFunction)] _StencilComp ("Stencil Comparison", Int) = 0
[Enum(UnityEngine.Rendering.StencilOp)] _StencilOp ("Stencil Operation", Int) = 0
[Enum(UnityEngine.Rendering.StencilOp)] _StencilFail ("Stencil Fail", Int) = 0
[Enum(UnityEngine.Rendering.StencilOp)] _StencilZFail ("Stencil ZFail", Int) = 0
// (省略)
// 目の透過を描画する時のステンシル
[Title(Eyelash)]
_EyelashStencil ("Stencil ID [0;255]", Int) = 1
_EyelashReadMask ("ReadMask [0;255]", Int) = 255
_EyelashWriteMask ("WriteMask [0;255]", Int) = 255
[Enum(UnityEngine.Rendering.CompareFunction)] _EyelashStencilComp ("Stencil Comparison", Int) = 0
[Enum(UnityEngine.Rendering.StencilOp)] _EyelashStencilOp ("Stencil Operation", Int) = 0
[Enum(UnityEngine.Rendering.StencilOp)] _EyelashStencilFail ("Stencil Fail", Int) = 0
[Enum(UnityEngine.Rendering.StencilOp)] _EyelashStencilZFail ("Stencil ZFail", Int) = 0
}
SubShader
{
Tags {"RenderType" = "Opaque" "IgnoreProjector" = "True" "RenderPipeline" = "UniversalPipeline"}
// パス1. 通常描画を行うパス
Pass
{
Name "Base"
Tags
{
"LightMode" = "SRPDefaultUnlit"
}
Stencil {
Ref [_Stencil]
ReadMask [_ReadMask]
WriteMask [_WriteMask]
Comp [_StencilComp]
Pass [_StencilOp]
Fail [_StencilFail]
ZFail [_StencilZFail]
}
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "BasePass.hlsl"
ENDHLSL
}
// パス2. 目の透過用の描画パス
Pass
{
Name "Eyelash"
Tags
{
"LightMode" = "SampleEyelash"
}
Cull Back
ZTest Always
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha, Zero One
Stencil {
Ref [_EyelashStencil]
ReadMask [_EyelashReadMask]
WriteMask [_EyelashWriteMask]
Comp [_EyelashStencilComp]
Pass [_EyelashStencilOp]
Fail [_EyelashStencilFail]
ZFail [_EyelashStencilZFail]
}
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "EyelashPass.hlsl"
ENDHLSL
}
// (省略)
...
}
}
2. ステンシルの設定
ステンシルを設定するC#コードの例を示します。
まずは、マテリアルのタイプに応じたステンシル設定値を作成するファクトリクラスCharacterStencilDataFactoryを用意します。
using UnityEngine.Rendering;
namespace Sample
{
/// <summary>
/// キャラクターマテリアル情報
/// </summary>
public struct CharacterMaterialData
{
// レンダーキュー
public uint RenderQueue;
// 目の通常描画時のステンシル
public CharacterStencilData BaseStencil;
// 目の透過描画時のステンシル
public CharacterStencilData OverlayStencil;
}
/// <summary>
/// ステンシルデータ
/// </summary>
public struct CharacterStencilData
{
public uint StencilRef;
public uint ReadMask;
public uint WriteMask;
public CompareFunction Comp;
public StencilOp Pass;
public StencilOp Fail;
public StencilOp ZFail;
}
/// <summary>
/// マテリアルの種類
/// </summary>
public enum CharacterMaterialType
{
Face, // 顔
Eye, // 目
Hair, // 髪
Other, // その他
}
/// <summary>
/// ステンシルやRenderQueueのデータを作成するFactoryクラス
/// </summary>
public static class CharacterStencilDataFactory
{
/// <summary>
/// マテリアル情報の作成
/// </summary>
/// <param name="type">マテリアルの種類</param>
/// <param name="formationId">キャラ番号</param>
public static CharacterMaterialData Create(CharacterMaterialType type, int formationId)
{
CharacterMaterialData data = default;
switch(type)
{
case CharacterMaterialType.Face:
data = new CharacterMaterialData
{
RenderQueue = 2000, // RenderQueue.Geometry
BaseStencil = new CharacterStencilData
{
StencilRef = 0,
ReadMask = 255,
WriteMask = 255,
Comp = CompareFunction.Always,
Pass = StencilOp.Replace,
Fail = StencilOp.Keep,
ZFail = StencilOp.Keep,
},
OverlayStencil = new CharacterStencilData
{
StencilRef = 0,
ReadMask = 255,
WriteMask = 255,
Comp = CompareFunction.Disabled,
Pass = StencilOp.Keep,
Fail = StencilOp.Keep,
ZFail = StencilOp.Keep,
},
};
break;
case CharacterMaterialType.Eye:
data = new CharacterMaterialData
{
RenderQueue = type switch
{
// 描画順 : まつ毛・眉毛 -> 瞳
CharacterMaterialType.Eye => 2002, // 瞳
CharacterMaterialType.Eyebrow => 2001, // 眉毛
CharacterMaterialType.Eyelash => 2001, // まつ毛
_ => 2001,
},
BaseStencil = new CharacterStencilData
{
StencilRef = 255, // 0b11111111
ReadMask = 255, // 0b11111111
WriteMask = 1u << formationId, // キャラ番号に対応する桁のビット列を書き換える
Comp = CompareFunction.Always,
Pass = StencilOp.Replace,
Fail = StencilOp.Keep,
ZFail = StencilOp.Keep,
},
OverlayStencil = new CharacterStencilData
{
StencilRef = 1u << formationId, // キャラ番号に対応する桁のビット列が1になっていたら成功
ReadMask = 1u << formationId, // キャラ番号に対応するビット列を見てステンシルテストする
WriteMask = 1u << formationId, // キャラ番号に対応する桁のビット列を書き換え
Comp = CompareFunction.Equal,
Pass = StencilOp.Zero, // 透過の2重描画を回避するため、ステンシルバッファに0を書き込む
Fail = StencilOp.Keep,
ZFail = StencilOp.Keep,
},
};
break;
case CharacterMaterialType.Hair:
data = new CharacterMaterialData
{
RenderQueue = 2451, // RenderQueue.AlphaTest + 1
BaseStencil = new CharacterStencilData
{
StencilRef = 0, // 0b00000000
ReadMask = 255, // 0b11111111
WriteMask = ~(1u << formationId) & 255, // キャラ番号に対応する桁を除いたビット列を書き換える (ビット反転すると、最上位ビット列(符号)も反転してしまうので、255でマスクする)
Comp = CompareFunction.Always,
Pass = StencilOp.Replace,
Fail = StencilOp.Keep,
ZFail = StencilOp.Keep,
},
OverlayStencil = new CharacterStencilData
{
StencilRef = 0, // 0b00000000
ReadMask = 255, // 0b11111111
WriteMask = 255, // 0b11111111
Comp = CompareFunction.Disabled,
Pass = StencilOp.Keep,
Fail = StencilOp.Keep,
ZFail = StencilOp.Keep,
},
};
break;
case CharacterMaterialType.Other:
data = new CharacterMaterialData
{
RenderQueue = 2000, // RenderQueue.Geometry
BaseStencil = new CharacterStencilData
{
StencilRef = 0,
ReadMask = 255,
WriteMask = 255,
Comp = CompareFunction.Always,
Pass = StencilOp.Replace,
Fail = StencilOp.Keep,
ZFail = StencilOp.Keep,
},
OverlayStencil = new CharacterStencilData
{
StencilRef = 0,
ReadMask = 255,
WriteMask = 255,
Comp = CompareFunction.Disabled,
Pass = StencilOp.Keep,
Fail = StencilOp.Keep,
ZFail = StencilOp.Keep,
},
};
break;
default:
Debug.LogWarning($"ステンシル取得に失敗しました : formationId={formationId} CharacterMaterialType={type}");
break;
}
return data;
}
}
}
次に、作成したステンシルをマテリアルに渡す実装を以下に示します。
/// <summary>
/// マテリアルのステンシル設定
/// </summary>
/// <param name="formationId">キャラ番号(0: 最初のキャラクター)</param>
/// <param name="material">設定対象のマテリアル</param>
private static void SetStencil(Material material, int formationId)
{
CharacterMaterialData data = CharacterStencilDataFactory.Create(type, formationId);
material.renderQueue = (int)data.RenderQueue;
SetBaseStencil(material, data.BaseStencil);
SetOverlayStencil(material, data.OverlayStencil);
}
/// <summary>
/// ベース描画時のステンシル値を設定
/// </summary>
static void SetBaseStencil(Material material, CharacterStencilData data)
{
material.SetInt(ShaderPropertyId.Stencil, (int)data.StencilRef);
material.SetInt(ShaderPropertyId.ReadMask, (int)data.ReadMask);
material.SetInt(ShaderPropertyId.WriteMask, (int)data.WriteMask);
material.SetInt(ShaderPropertyId.StencilComp, (int)data.Comp);
material.SetInt(ShaderPropertyId.StencilOp, (int)data.Pass);
material.SetInt(ShaderPropertyId.StencilFail, (int)data.Fail);
material.SetInt(ShaderPropertyId.StencilZFail, (int)data.ZFail);
}
/// <summary>
/// 透過描画のステンシルの値を設定
/// </summary>
static void SetOverlayStencil(Material material, CharacterStencilData data)
{
material.SetInt(ShaderPropertyId.EyelashStencil, (int)data.StencilRef);
material.SetInt(ShaderPropertyId.EyelashReadMask, (int)data.ReadMask);
material.SetInt(ShaderPropertyId.EyelashWriteMask, (int)data.WriteMask);
material.SetInt(ShaderPropertyId.EyelashStencilComp, (int)data.Comp);
material.SetInt(ShaderPropertyId.EyelashStencilOp, (int)data.Pass);
material.SetInt(ShaderPropertyId.EyelashStencilFail, (int)data.Fail);
material.SetInt(ShaderPropertyId.EyelashStencilZFail, (int)data.ZFail);
}
/// <summary>
/// シェーダープロパティIdの定義
/// </summary>
internal static class ShaderPropertyId
{
// ステンシル
public static readonly int Stencil = Shader.PropertyToID("_Stencil");
public static readonly int ReadMask = Shader.PropertyToID("_ReadMask");
public static readonly int WriteMask = Shader.PropertyToID("_WriteMask");
public static readonly int StencilComp = Shader.PropertyToID("_StencilComp");
public static readonly int StencilOp = Shader.PropertyToID("_StencilOp");
public static readonly int StencilFail = Shader.PropertyToID("_StencilFail");
public static readonly int StencilZFail = Shader.PropertyToID("_StencilZFail");
// ステンシル (目)
public static readonly int EyelashStencil = Shader.PropertyToID("_EyelashStencil");
public static readonly int EyelashReadMask = Shader.PropertyToID("_EyelashReadMask");
public static readonly int EyelashWriteMask = Shader.PropertyToID("_EyelashWriteMask");
public static readonly int EyelashStencilComp = Shader.PropertyToID("_EyelashStencilComp");
public static readonly int EyelashStencilOp = Shader.PropertyToID("_EyelashStencilOp");
public static readonly int EyelashStencilFail = Shader.PropertyToID("_EyelashStencilFail");
public static readonly int EyelashStencilZFail = Shader.PropertyToID("_EyelashStencilZFail");
}
終わりに
今回の記事では、目の透過の実装手法を4つほど紹介いたしました。
モバイルゲーム開発におけるステンシルバッファの活用例の1つとして見ていただけると幸いです。