Unityで、PBRなライティング環境をセットアップしてみよう


こんにちは。セガ・インタラクティブ技術統括室の大森です。
TAとして、アーケードタイトルにUnityを採用する際の描画設計を お手伝いしています。


Unity5以降、Unityの標準シェーダーにはPBRワークフローが採用されています。アート素材の量産性が高いことから すっかり普及したPBRワークフローですが、その恩恵を受ける為にはフォトリアル系の知識が欠かせません。

ところで、Unityプロジェクトの初期設定は、旧来シェーダーでの絵作りとも互換性を保つように設定されていて、そのままPBRワークフローを始めると混乱を招く部分があります。社内では この混乱を避ける為、プロジェクト立ち上げ時に、カラースペースやライティング単位を設定しておく事を勧めています。


この記事では、ライティング環境のセットアップに使えるシェーダーやスクリプトを共有しつつ、具体的なライティング設定の一例を紹介します。ぜひハンズオンで、自作のモデルやステージデータを使って、試しながら読んで頂けたらと思います。


f:id:katsuhiko_omori:20190402225501j:plain
このスクリーンショットで、ターゲットとした屋外ライティング環境は以下のようになります。

太陽に直交した面に 入ってくる光の量(照度) 太陽から 85,000 青空(半球)から 20,000 合計    105,000 [lux]
太陽の傾斜角が66.6度。水平地面に 入ってくる光の量(照度) 太陽から 78,000 青空(半球)から 20,000 合計    98,000 [lux]
この環境に照らされたグレー18.42%地面の明るさ(輝度) 青空から=日陰   1,173 合計=日向    5,747 [nt]
青空の明るさ(輝度) 平均     6,366 [nt]
白い雲    10,000 [nt]
水平線    8,000 [nt]
青い空    4,000 [nt]
天頂部    1,500 [nt]
カメラの露出補正(EV100準拠の絶対補正値) 15 [EV]

…これが常に正しい値!という訳ではなく、現実にありうる値の一例として。
この環境をセットアップしていきます。



Unityプロジェクトを作成する

今回のハンズオンは、Unity2018.3以降に対応しています。*1
標準の フォワードレンダリング+HDRカメラ設定を、そのまま使用して進めます。
ポスプロにはPost Processing V2を、シェーダー内部のライブラリとしてCore RP Libraryも利用します。

まずは、新しいプロジェクトを作り、必要なパッケージとスクリプトをインストールしましょう。

必要なパッケージをインストールする

  1. Unityを起動し、プロジェクトを新規作成。Templateは3Dにする
    f:id:sgtech:20190422122620g:plain
  2. PostProcessingとRender-Pipelines.Coreのパッケージをインストールする
    • パッケージマネージャ ウィンドウを開く Window > Package Manager
      f:id:sgtech:20190422122817g:plain
    • Advanced > Show Preview Packages をオンにして プレビューパッケージを表示する
      f:id:sgtech:20190422122815g:plain
    • PostProcessingをインストールする
      f:id:sgtech:20190422122813g:plain
    • Render-Pipelines.Coreをインストールする
      f:id:sgtech:20190422122811g:plain


SEGA TECH Blogからスクリプトをインポートする

…すみません、このBlog、スクリプトファイルを直接添付することが 出来ません。
お手数おかけしますが、以下3つのスクリプトを テキストファイルに コピペ, 保存し、
Unityプロジェクトにインポートしてご利用ください。

  1. それぞれの、▼ファイル名 をクリックして、コード内容を表示
  2. 表示されたコードをダブルクリックすると全文選択されるので、右クリック>コピーする
  3. メモ帳に コードをペーストし、それぞれ指定のファイル名で、Unityプロジェクトの Assetsフォルダ内へ保存する

litColorSpace.cs

using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;

namespace SegaTechBlog
{
    public class litColorSpace : MonoBehaviour
    {
#if UNITY_EDITOR
        [MenuItem("SegaTechBlog/LightsIntensity/Linear")]
        private static void luliTrue()
        {
            GraphicsSettings.lightsUseLinearIntensity = true;
            EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo();
            var scn = EditorSceneManager.GetSceneManagerSetup();
            EditorSceneManager.OpenScene(scn[0].path);
        }

        [MenuItem("SegaTechBlog/LightsIntensity/Gamma")]
        private static void luliFalse()
        {
            GraphicsSettings.lightsUseLinearIntensity = false;
            EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo();
            var scn = EditorSceneManager.GetSceneManagerSetup();
            EditorSceneManager.OpenScene(scn[0].path);
        }
#endif
    }
}

litChkLib.hlsl

#ifndef SEGATB_CHS_INCLUDED
#define SEGATB_CHS_INCLUDED
// ------------------------------------------------------------------------------------
// SEGATB _ COMMON FOR ALL PASS
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
CBUFFER_START(UnityPerCamera)
	float4 _Time;	float3 _WorldSpaceCameraPos;	float4 _ProjectionParams;	float4 _ScreenParams;	float4 _ZBufferParams;	float4 unity_OrthoParams;
CBUFFER_END
CBUFFER_START(UnityPerCameraRare)
	float4x4 unity_CameraToWorld;
CBUFFER_END
CBUFFER_START(UnityLighting)
	float4 _WorldSpaceLightPos0;
	float4 unity_4LightPosX0;	float4 unity_4LightPosY0;	float4 unity_4LightPosZ0;	half4 unity_4LightAtten0;	half4 unity_LightColor[8];
	half4 unity_DynamicLightmap_HDR;
CBUFFER_END
CBUFFER_START(UnityShadows)
	float4 unity_LightShadowBias;
CBUFFER_END
CBUFFER_START(UnityPerDraw)
	float4x4 unity_ObjectToWorld;	float4x4 unity_WorldToObject;	float4 unity_LODFade;	float4 unity_WorldTransformParams;
	real4 unity_SpecCube0_HDR;
	float4 unity_LightmapST;	float4 unity_DynamicLightmapST;
	real4 unity_SHAr;	real4 unity_SHAg;	real4 unity_SHAb;	real4 unity_SHBr;	real4 unity_SHBg;	real4 unity_SHBb;	real4 unity_SHC;
CBUFFER_END
CBUFFER_START(UnityPerFrame)
	float4x4 glstate_matrix_projection;	float4x4 unity_MatrixV;	float4x4 unity_MatrixInvV;	float4x4 unity_MatrixVP;
CBUFFER_END
CBUFFER_START(UnityReflectionProbes)
	float4 unity_SpecCube0_BoxMax;	float4 unity_SpecCube0_BoxMin;	float4 unity_SpecCube0_ProbePosition;
CBUFFER_END
#define UNITY_MATRIX_M     unity_ObjectToWorld
#define UNITY_MATRIX_I_M   unity_WorldToObject
#define UNITY_MATRIX_V     unity_MatrixV
#define UNITY_MATRIX_I_V   unity_MatrixInvV
#define UNITY_MATRIX_P     OptimizeProjectionMatrix(glstate_matrix_projection)
#define UNITY_MATRIX_VP    unity_MatrixVP
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl"
 
float4x4 OptimizeProjectionMatrix(float4x4 M)
{
    M._21_41 = 0;
    M._12_42 = 0;
    return M;
}

float3 CheckColorValue(float3 color, float targetValue, float targetScale, float range)
{
    targetValue *= targetScale;
    float lum = dot(color, float3(0.2126729, 0.7151522, 0.072175));
    float3 outColor;
    outColor.g = saturate(max(range - abs(lum - targetValue), 0.0) * 10000) * 1.2; // just in range
    outColor.r = saturate(max(lum - targetValue + range, 0.0) * 10000) - outColor.g * 0.5; // over    range
    outColor.b = saturate(max(targetValue - lum + range, 0.0) * 10000) - outColor.g * 0.5; // under   range

    float rhythm = sin(lum / targetScale * 10.0 + _Time.w) * 0.35;
    outColor.g += 0.123;
    return outColor * (0.65 + rhythm);
}

// ------------------------------------------------------------------------------------
//
#ifdef SEGATB_FORWARD

#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonLighting.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/BSDF.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/EntityLighting.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/ImageBasedLighting.hlsl"

float _ChkTargetValue, _ChkTargetScale, _ChkRange;
half4 _LightColor0;

UNITY_INSTANCING_BUFFER_START(PerInstance)
	UNITY_DEFINE_INSTANCED_PROP(float4, _AlbedoColor)
	UNITY_DEFINE_INSTANCED_PROP(float, _Metallic)
	UNITY_DEFINE_INSTANCED_PROP(float, _Anisotropy)
	UNITY_DEFINE_INSTANCED_PROP(float, _Smoothness)
	UNITY_DEFINE_INSTANCED_PROP(float, _EmitIntensity)
UNITY_INSTANCING_BUFFER_END(PerInstance)

TEXTURE2D_SHADOW(_ShadowMapTexture);	SAMPLER(sampler_ShadowMapTexture);
TEXTURECUBE(unity_SpecCube0);			SAMPLER(samplerunity_SpecCube0);
TEXTURE2D(unity_Lightmap);				SAMPLER(samplerunity_Lightmap);
TEXTURE2D(unity_LightmapInd);
TEXTURE2D(unity_DynamicLightmap);		SAMPLER(samplerunity_DynamicLightmap);
TEXTURE2D(unity_DynamicDirectionality);
TEXTURE2D(_MainTex);					SAMPLER(sampler_MainTex);
TEXTURE2D(_MetallicGlossMap);			SAMPLER(sampler_MetallicGlossMap);
TEXTURE2D(_NormalMap);					SAMPLER(sampler_NormalMap);

// ------------------------------------------------------------------
struct VertexInput
{
    float4 posOS	 : POSITION;
    float3 normalOS  : NORMAL;
    float4 tangentOS : TANGENT;
    float4 uv0		 : TEXCOORD0;
    float2 uvLM		 : TEXCOORD1;
    float2 uvDLM	 : TEXCOORD2;
    float4 vColor	 : COLOR;
	UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct VertexOutput
{
    float4 posCS					 : SV_POSITION;
    float4 uv						 : TEXCOORD0;
    float4 tangentToWorldAndPosWS[3] : TEXCOORD1;
    float3 viewDirWS				 : TEXCOORD4;
    float4 posNDC					 : TEXCOORD5;
    float4 ambientOrLightmapUV		 : TEXCOORD6;
	UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct GeometrySTB
{
    float3 posWS;
    float3 verNormalWS;
    float3 normalWS;
    float3 tangentWS;
    float3 binormalWS;
};
struct CameraSTB
{
    float3 posWS;
    float3 dirWS;
    float  distanceWS;
    float2 pixelPosSCS;
};
struct LightSTB
{
    float3 dirWS;
    float3 color;
    float  atten;
};
struct SubLightsGeometrySTB
{
    float3 lightVectorWS[4];
    float  distanceSqr[4];
    float  lightAtten[4];
};
struct MaterialSTB
{
    float3 albedoColor;
    float3 reflectColor;
    float  grazingTerm;
    float  alpha;
    float  perceptualRoughness;
    float2 anisoRoughness;
    float  surfaceReduction;
    float  microOcclusion;
    float3 emitColor;
    float3 testValue;
    float  reflectOneForTest;
};
struct LitParamPerViewSTB
{
    float  specOcclusion;
    float  NdotV;
    float  envRefl_fv;
    float3 reflViewWS;
    float  partLambdaV;
};
struct LitParamPerLightSTB
{
    float3 specularColor;
    float3 diffuseColor;
    float3 testValue;
};
struct LitParamPerEnvironmentSTB
{
    float3 reflectColor;
    float3 diffuseColor;
    float3 testValue;
};

float4 GetPosNDC(float4 posCS)
{
	float4 posNDC;
	float4 ndc = posCS * 0.5f;
	posNDC.xy  = float2(ndc.x, ndc.y * _ProjectionParams.x) + ndc.w;
	posNDC.zw  = posCS.zw;
	return posNDC;
}

float F_Pow5(float u)
{
	float x = 1.0 - u;
	float x2 = x * x;
	float x5 = x * x2 * x2;
	return x5;
}

float3 BoxProjectedCubemapDirection(float3 reflViewWS, float3 posWS, float4 cubemapCenter, float4 boxMin, float4 boxMax)
{
    UNITY_BRANCH if (cubemapCenter.w > 0.0)
    {
        float3 nrdir = normalize(reflViewWS);
		float3 rbmax    = (boxMax.xyz - posWS) / nrdir;
		float3 rbmin    = (boxMin.xyz - posWS) / nrdir;
		float3 rbminmax = (nrdir > 0.0f) ? rbmax : rbmin;
        float  fa       = min(min(rbminmax.x, rbminmax.y), rbminmax.z);
        posWS     -= cubemapCenter.xyz;
        reflViewWS = posWS + nrdir * fa;
    }
    return reflViewWS;
}

// ------------------------------------------------------------------
GeometrySTB GetGeometry(VertexOutput input, float2 uv)
{
    GeometrySTB output;
    output.posWS = float3(input.tangentToWorldAndPosWS[0].w, input.tangentToWorldAndPosWS[1].w, input.tangentToWorldAndPosWS[2].w);	
    float3 verTangentWS  = input.tangentToWorldAndPosWS[0].xyz;
    float3 verBinormalWS = input.tangentToWorldAndPosWS[1].xyz;
    output.verNormalWS   = normalize(input.tangentToWorldAndPosWS[2].xyz);

#ifdef _NORMALMAP
    half4  normalMap  = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uv);
    float3 normalMapTS;
    normalMapTS.xy    = normalMap.wy *2.0 - 1.0;
    normalMapTS.z     = sqrt(1.0 - saturate(dot(normalMapTS.xy, normalMapTS.xy)));
    output.normalWS   = normalize(verTangentWS * normalMapTS.x + verBinormalWS * normalMapTS.y + output.verNormalWS * normalMapTS.z);
    output.tangentWS  = normalize(verTangentWS - dot(verTangentWS, output.normalWS) * output.normalWS);
    float3 newBB      = cross(output.normalWS, output.tangentWS);
    output.binormalWS = newBB * FastSign(dot(newBB, verBinormalWS));
#else
    output.normalWS   = output.verNormalWS;
    output.tangentWS  = normalize(verTangentWS);
    output.binormalWS = normalize(verBinormalWS);
#endif
    return output;
}

CameraSTB GetCamera(VertexOutput input, GeometrySTB geo)
{
    CameraSTB output;
    output.posWS       = _WorldSpaceCameraPos;
    output.dirWS       = normalize(input.viewDirWS);
    output.distanceWS  = LinearEyeDepth(geo.posWS, UNITY_MATRIX_V);
    output.pixelPosSCS = input.posNDC.xy / input.posNDC.w;
    return output;
}

LightSTB GetMainLight(CameraSTB cam)
{
    LightSTB output;
#if defined (DIRECTIONAL) || defined (DIRECTIONAL_COOKIE)
 #if defined(_NOPIDIV) && !defined(_VSUN_LIGHT_COLOR) && !defined(_VPOINT_LIGHT_COLOR)
    output.color = _LightColor0.rgb *PI;
 #else
    output.color = _LightColor0.rgb;
 #endif
    half atten = 1.0;
 #if defined(SHADOWS_SCREEN)
	atten = SAMPLE_TEXTURE2D(_ShadowMapTexture, sampler_ShadowMapTexture, cam.pixelPosSCS).x;
 #endif
	output.atten = atten;
    output.dirWS = _WorldSpaceLightPos0.xyz;
#else
	output.color = 0;
	output.atten = 0;
	output.dirWS = float3(0,0,1);
#endif
	return output;
}

SubLightsGeometrySTB GetSubLightsGeometry(GeometrySTB geo)
{
    SubLightsGeometrySTB output;
    float4 toLightX = unity_4LightPosX0 - geo.posWS.x;
    float4 toLightY = unity_4LightPosY0 - geo.posWS.y;
    float4 toLightZ = unity_4LightPosZ0 - geo.posWS.z;
    float4 distanceSqr = 0.0;
    distanceSqr += toLightX * toLightX;
    distanceSqr += toLightY * toLightY;
    distanceSqr += toLightZ * toLightZ;
    output.lightVectorWS[0] = float3(toLightX.x, toLightY.x, toLightZ.x);
    output.lightVectorWS[1] = float3(toLightX.y, toLightY.y, toLightZ.y);
    output.lightVectorWS[2] = float3(toLightX.z, toLightY.z, toLightZ.z);
    output.lightVectorWS[3] = float3(toLightX.w, toLightY.w, toLightZ.w);
    output.distanceSqr[0] = distanceSqr.x;
    output.distanceSqr[1] = distanceSqr.y;
    output.distanceSqr[2] = distanceSqr.z;
    output.distanceSqr[3] = distanceSqr.w;
    output.lightAtten[0] = unity_4LightAtten0.x;
    output.lightAtten[1] = unity_4LightAtten0.y;
    output.lightAtten[2] = unity_4LightAtten0.z;
    output.lightAtten[3] = unity_4LightAtten0.w;
    return output;
}

LightSTB GetSubLight(uint index, SubLightsGeometrySTB subLightsGeo)
{
    LightSTB output;
#if defined(_NOPIDIV) && !defined(_VSUN_LIGHT_COLOR) && !defined(_VPOINT_LIGHT_COLOR)
    output.color = unity_LightColor[index].xyz * PI;
#else
    output.color = unity_LightColor[index].xyz;
#endif

    UNITY_BRANCH if ((output.color.r + output.color.g + output.color.b) != 0.0)
    {
        float distanceSqr = max(subLightsGeo.distanceSqr[index], (PUNCTUAL_LIGHT_THRESHOLD * PUNCTUAL_LIGHT_THRESHOLD));
#if defined(_NOPIDIV)
		output.atten = 1.0 / (1.0 + distanceSqr * subLightsGeo.lightAtten[index]);
#else
        float invDistanceSqr   = 1.0 / distanceSqr;
        float lightAttenFactor = distanceSqr * subLightsGeo.lightAtten[index] * 0.04;
		lightAttenFactor      *= lightAttenFactor;
		lightAttenFactor       = saturate(1.0 - lightAttenFactor);
		lightAttenFactor      *= lightAttenFactor;
        output.atten = max(invDistanceSqr * lightAttenFactor, 0.0);
#endif
        output.dirWS = SafeNormalize(subLightsGeo.lightVectorWS[index]);
    }
    else
    {
        output.atten = 0.0;
        output.dirWS = float3(0,0,1);
    }
    return output;
}

MaterialSTB GetMaterial(float2 uv)
{
    MaterialSTB output;
    half4 colParams = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
    half4 matParams = SAMPLE_TEXTURE2D(_MetallicGlossMap, sampler_MetallicGlossMap, uv);
    float4 matColor   = UNITY_ACCESS_INSTANCED_PROP(PerInstance, _AlbedoColor);
    float  metallic   = UNITY_ACCESS_INSTANCED_PROP(PerInstance, _Metallic);
    float  anisotropy = UNITY_ACCESS_INSTANCED_PROP(PerInstance, _Anisotropy);
    float  smoothness = UNITY_ACCESS_INSTANCED_PROP(PerInstance, _Smoothness);
    float  emmision   = UNITY_ACCESS_INSTANCED_PROP(PerInstance, _EmitIntensity);
    float  occlusion  = 1.0;
#ifdef _COLMAP
    matColor   *= colParams;
#endif
#ifdef _METMAP
    metallic   *= matParams.x;
#endif
#ifdef _OCCMAP
    occlusion  *= matParams.y;
#endif
#ifdef _SMTMAP
    smoothness *= matParams.w;
#endif

    float oneMinusReflectivity = (1.0 - metallic) * 0.96;
    output.albedoColor  = matColor.rgb * oneMinusReflectivity;
    output.reflectColor = lerp(half3(0.04, 0.04, 0.04), matColor.rgb, metallic);
    output.grazingTerm  = saturate(smoothness + (1.0 - oneMinusReflectivity));
    output.alpha        = matColor.a;
    output.perceptualRoughness = PerceptualSmoothnessToPerceptualRoughness(smoothness);
    ConvertAnisotropyToRoughness(output.perceptualRoughness, anisotropy, output.anisoRoughness.x, output.anisoRoughness.y);
    output.anisoRoughness.x    = max(output.anisoRoughness.x, 0.0005);
    output.anisoRoughness.y    = max(output.anisoRoughness.y, 0.0005);
    output.surfaceReduction    = 1.0 / (output.perceptualRoughness * output.perceptualRoughness + 1.0);
    output.microOcclusion = occlusion;
    output.emitColor      = matColor.rgb * emmision;

#if defined(_VMAT_COLOR)
    output.testValue = matColor.rgb;
#elif defined(_VMAT_DIFFUSE_COLOR)
	output.testValue = output.albedoColor;
#elif defined(_VMAT_METALLIC)
	output.testValue = metallic;
#elif defined(_VMAT_SMOOTHNESS)
	output.testValue = smoothness;
#elif defined(_VMAT_OCCLUSION)
	output.testValue = occlusion;
#else
    output.testValue = 0;
#endif
    output.reflectOneForTest = lerp(0.04, 1.0, metallic);
    return output;
}

LitParamPerViewSTB GetLitParamPerView(GeometrySTB geo, CameraSTB cam, MaterialSTB mat)
{
    LitParamPerViewSTB output;
    output.specOcclusion = GetHorizonOcclusion(cam.dirWS, geo.normalWS, geo.verNormalWS, 0.8);
    output.NdotV = ClampNdotV(dot(geo.normalWS, cam.dirWS));
    output.envRefl_fv = F_Pow5(saturate(output.NdotV));
    output.reflViewWS = reflect(-cam.dirWS, geo.normalWS);
    float TdotV        = dot(geo.tangentWS,  cam.dirWS);
    float BdotV        = dot(geo.binormalWS, cam.dirWS);
    output.partLambdaV = GetSmithJointGGXAnisoPartLambdaV(TdotV, BdotV, output.NdotV, mat.anisoRoughness.x, mat.anisoRoughness.y);
    return output;
}

LitParamPerLightSTB GetLitByTheLight(GeometrySTB geo, CameraSTB cam, MaterialSTB mat, LitParamPerViewSTB lip, LightSTB theLight)
{
    LitParamPerLightSTB output;
    float NdotL = dot(geo.normalWS, theLight.dirWS);
#if defined(_VSUN__) && defined(_VPOINT__)
    UNITY_BRANCH if (NdotL > 0.0)
    {
#endif
        float3 halfDir = SafeNormalize(theLight.dirWS + cam.dirWS);
        float LdotV = dot(theLight.dirWS, cam.dirWS);
        float NdotH = dot(geo.normalWS,   halfDir);
        float LdotH = dot(theLight.dirWS, halfDir);
        float TdotL = dot(geo.tangentWS,  theLight.dirWS);
        float BdotL = dot(geo.binormalWS, theLight.dirWS);
        float TdotH = dot(geo.tangentWS,  halfDir);
        float BdotH = dot(geo.binormalWS, halfDir);
        float spec_fv = F_Pow5(saturate(LdotH));
		float  occlusion    = ComputeMicroShadowing(mat.microOcclusion * 1.6 +0.2, NdotL, 1.0);
		float3 occlusionCol = GTAOMultiBounce(occlusion, mat.albedoColor);

        float  specTermD     = D_GGXAniso(TdotH, BdotH, NdotH, mat.anisoRoughness.x, mat.anisoRoughness.y);
        float  specTermG     = V_SmithJointGGXAniso(0, 0, lip.NdotV, TdotL, BdotL, NdotL, mat.anisoRoughness.x, mat.anisoRoughness.y, lip.partLambdaV);
        float3 specTermF     = mat.reflectColor + (1 - mat.reflectColor) * spec_fv;
		output.specularColor = (specTermD * specTermG * saturate(NdotL) * theLight.atten * occlusion * lip.specOcclusion) * specTermF * theLight.color;

        float  diffuseTerm   = DisneyDiffuse(lip.NdotV, NdotL, LdotV, mat.perceptualRoughness);
        output.diffuseColor  = (diffuseTerm * saturate(NdotL) * theLight.atten * occlusionCol) * theLight.color;

#if defined(_VSUN_LIGHT_COLOR) || defined(_VPOINT_LIGHT_COLOR)
		output.testValue = theLight.color;
#elif defined(_VSUN_LIGHT_ILLUMINANCE) || defined(_VPOINT_LIGHT_ILLUMINANCE) || defined(_VGET_TOTAL_ILLUMINANCE)
		output.testValue = theLight.color *saturate(NdotL) * theLight.atten * occlusion;
#elif defined(_VSUN_SHADE_LAMBERT) || defined(_VPOINT_SHADE_LAMBERT)
		output.testValue = theLight.color *saturate(NdotL) * theLight.atten * occlusion *INV_PI;
#elif defined(_VSUN_SHADE_SPECULAR) || defined(_VPOINT_SHADE_SPECULAR) || defined(_VGET_TOTAL_REFLECTION)
		output.testValue = (specTermD * specTermG * saturate(NdotL) * theLight.atten * occlusion * lip.specOcclusion) * (mat.reflectOneForTest + (1 - mat.reflectOneForTest) * spec_fv) * theLight.color;
#elif defined(_VSUN_SHADE_SPEC_DGF) || defined(_VPOINT_SHADE_SPEC_DGF)
		output.testValue.r = specTermD;
		output.testValue.g = specTermG;
		output.testValue.b = specTermF;
#elif defined(_VSUN_SHADE_SPEC_D) || defined(_VPOINT_SHADE_SPEC_D)
		output.testValue = specTermD;
#elif defined(_VSUN_SHADE_SPEC_G) || defined(_VPOINT_SHADE_SPEC_G)
		output.testValue = specTermG;
#elif defined(_VSUN_SHADE_SPEC_F) || defined(_VPOINT_SHADE_SPEC_F)
		output.testValue = mat.reflectOneForTest + (1 - mat.reflectOneForTest) * spec_fv;
#else
        output.testValue = 0;
#endif
#if defined(_VSUN__) && defined(_VPOINT__)
    }
    else
    {
        output.specularColor = 0.0;
		output.diffuseColor  = 0.0;
        output.testValue = 0;
    }
#endif
	return output;
}

LitParamPerEnvironmentSTB GetLitByEnvironment(VertexOutput input, GeometrySTB geo, MaterialSTB mat, LitParamPerViewSTB lip)
{
    LitParamPerEnvironmentSTB output;
	float  occlusion    = ComputeMicroShadowing(mat.microOcclusion * 0.8 +0.3, lip.NdotV, 1.0);
	float3 occlusionCol = GTAOMultiBounce( saturate(mat.microOcclusion *1.2), mat.albedoColor);

#if defined(LIGHTPROBE_SH)
    output.diffuseColor      = max( SHEvalLinearL0L1(geo.normalWS, unity_SHAr, unity_SHAg, unity_SHAb)+ input.ambientOrLightmapUV.rgb, 0.0);
#elif defined(DIRLIGHTMAP_COMBINED)
	half4 decodeInstructions = half4(LIGHTMAP_HDR_MULTIPLIER, LIGHTMAP_HDR_EXPONENT, 0.0h, 0.0h);
	{
		float4 direction          = SAMPLE_TEXTURE2D(unity_LightmapInd, samplerunity_Lightmap, input.ambientOrLightmapUV.xy);
		float4 encodedIlluminance = SAMPLE_TEXTURE2D(unity_Lightmap,    samplerunity_Lightmap, input.ambientOrLightmapUV.xy);
		float3 illuminance        = DecodeLightmap(encodedIlluminance, decodeInstructions);
		float  halfLambert        = dot(geo.normalWS, direction.xyz - 0.5) + 0.5;
		output.diffuseColor       = illuminance * halfLambert / max(1e-4, direction.w);
	}
 #if defined(DYNAMICLIGHTMAP_ON)
	{
		float4 direction          = SAMPLE_TEXTURE2D(unity_DynamicDirectionality, samplerunity_DynamicLightmap, input.ambientOrLightmapUV.zw);
		float4 encodedIlluminance = SAMPLE_TEXTURE2D(unity_DynamicLightmap,		  samplerunity_DynamicLightmap, input.ambientOrLightmapUV.zw);
		float3 illuminance        = DecodeLightmap(encodedIlluminance, decodeInstructions);
		float  halfLambert        = dot(geo.normalWS, direction.xyz - 0.5) + 0.5;
		output.diffuseColor      += illuminance * halfLambert / max(1e-4, direction.w);
	}
 #endif
#else
    output.diffuseColor      = 0.0;
#endif
    output.diffuseColor *= occlusionCol;

#if defined(UNITY_SPECCUBE_BOX_PROJECTION)
    float3 reflViewWS = BoxProjectedCubemapDirection(lip.reflViewWS, geo.posWS, unity_SpecCube0_ProbePosition, unity_SpecCube0_BoxMin, unity_SpecCube0_BoxMax);
#else
    float3 reflViewWS = lip.reflViewWS;
#endif
    half  reflMipLevel      = PerceptualRoughnessToMipmapLevel(mat.perceptualRoughness);
    half4 encodedIrradiance = SAMPLE_TEXTURECUBE_LOD(unity_SpecCube0, samplerunity_SpecCube0, reflViewWS, reflMipLevel);
#if !defined(UNITY_USE_NATIVE_HDR)
    half3 irradiance = DecodeHDREnvironment(encodedIrradiance, unity_SpecCube0_HDR);
#else
    half3 irradiance = encodedIrradiance.rbg;
#endif
    output.reflectColor = mat.microOcclusion * mat.surfaceReduction * irradiance * lerp(mat.reflectColor, mat.grazingTerm, lip.envRefl_fv);

#if defined(_VENV_LIGHT_ILLUMINANCE)
	output.testValue = output.diffuseColor *PI;
#elif defined(_VENV_SHADE_LAMBERT)
	output.testValue = output.diffuseColor;
#elif defined(_VENV_SHADE_REFLECTION)
	output.testValue = mat.microOcclusion * mat.surfaceReduction * irradiance * lerp(1.0, mat.grazingTerm, lip.envRefl_fv);
#elif defined(_VMAT_SPECULAR_COLOR)
	output.testValue = lerp(mat.reflectColor, mat.grazingTerm, lip.envRefl_fv);
#elif defined(_VGET_TOTAL_ILLUMINANCE)
	output.testValue = output.diffuseColor *PI;
#elif defined(_VGET_TOTAL_REFLECTION)
	output.testValue = occlusion * mat.surfaceReduction * irradiance;
#else
    output.testValue = 0;
#endif
    return output;
}

// ------------------------------------------------------------------
VertexOutput ChsForwardVertex( VertexInput input)
{
    VertexOutput output;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, output);

    float4 posWS = mul(UNITY_MATRIX_M, float4(input.posOS.xyz, 1.0));
    output.posCS = mul(UNITY_MATRIX_VP, posWS);

    float3   camPosWS = _WorldSpaceCameraPos;
    output.viewDirWS  = camPosWS - posWS.xyz;

    float3   normalWS   = normalize( mul( (float3x3) UNITY_MATRIX_M, input.normalOS));
    float4   tangentWS  = float4( normalize( mul( (float3x3) UNITY_MATRIX_M, input.tangentOS.xyz)), input.tangentOS.w);
    float    sign       = tangentWS.w * unity_WorldTransformParams.w;
	float3   binormalWS = cross( normalWS, tangentWS.xyz) * sign;

	float4 ndc       = output.posCS * 0.5f;
	output.posNDC.xy = float2(ndc.x, ndc.y * _ProjectionParams.x) + ndc.w;
	output.posNDC.zw = output.posCS.zw;

#ifdef DIRLIGHTMAP_COMBINED
	output.ambientOrLightmapUV.xy  = input.uvLM.xy  * unity_LightmapST.xy        + unity_LightmapST.zw;
 #ifdef DYNAMICLIGHTMAP_ON
	output.ambientOrLightmapUV.zw  = input.uvDLM.xy * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw;
 #else
	output.ambientOrLightmapUV.zw  = 0;
 #endif
#elif LIGHTPROBE_SH
	output.ambientOrLightmapUV.rgb = SHEvalLinearL2(normalWS, unity_SHBr, unity_SHBg, unity_SHBb, unity_SHC);
	output.ambientOrLightmapUV.w   = 0;
#else
    output.ambientOrLightmapUV     = 0;
#endif

    output.uv.xy = input.uv0.xy;
    output.uv.zw = 0;
    output.tangentToWorldAndPosWS[0].xyz = tangentWS.xyz;
    output.tangentToWorldAndPosWS[1].xyz = binormalWS;
    output.tangentToWorldAndPosWS[2].xyz = normalWS;
    output.tangentToWorldAndPosWS[0].w = posWS.x;
    output.tangentToWorldAndPosWS[1].w = posWS.y;
    output.tangentToWorldAndPosWS[2].w = posWS.z;
    return output;
}

float4 ChsForwardFragment( VertexOutput input ) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(input);
    float2             uv  = input.uv.xy;
    GeometrySTB        geo = GetGeometry(input, uv);
    CameraSTB          cam = GetCamera(input, geo);	
    MaterialSTB        mat = GetMaterial(uv);
    LitParamPerViewSTB lip = GetLitParamPerView(geo, cam, mat);

    LightSTB            sun    = GetMainLight(cam);
    LitParamPerLightSTB litSun = GetLitByTheLight(geo, cam, mat, lip, sun);

    LitParamPerEnvironmentSTB litEnv = GetLitByEnvironment(input, geo, mat, lip);

	LitParamPerLightSTB litSubLights;
	litSubLights.diffuseColor  = 0.0;
	litSubLights.specularColor = 0.0;
	litSubLights.testValue     = 0.0;
#ifdef LIGHTPROBE_SH
 #ifdef VERTEXLIGHT_ON
	SubLightsGeometrySTB subLightsGeo = GetSubLightsGeometry(geo);
	for (int i = 0; i < 3; i++) {
		LightSTB subLight = GetSubLight(i, subLightsGeo);
        UNITY_BRANCH if (subLight.atten != 0.0)
		{
            LitParamPerLightSTB litSubLight = GetLitByTheLight(geo, cam, mat, lip, subLight);
			litSubLights.diffuseColor  += litSubLight.diffuseColor;
			litSubLights.specularColor += litSubLight.specularColor;
			litSubLights.testValue     += litSubLight.testValue;
		}
	}
 #endif
#endif

    float3 color = ( litSun.diffuseColor + litEnv.diffuseColor + litSubLights.diffuseColor ) * mat.albedoColor + litSun.specularColor + litEnv.reflectColor + litSubLights.specularColor + mat.emitColor;
	float  alpha = mat.alpha;

#if defined(_VMAT_COLOR) || defined(_VMAT_DIFFUSE_COLOR) || defined(_VMAT_METALLIC) || defined(_VMAT_SMOOTHNESS) || defined(_VMAT_OCCLUSION)
	color = mat.testValue;
#elif defined(_VGET_TOTAL_ILLUMINANCE) || defined(_VGET_TOTAL_REFLECTION)
	color = litSun.testValue + litEnv.testValue + litSubLights.testValue;
#elif defined(_VGET_SUN_ONLY)
	color = litSun.diffuseColor * mat.albedoColor + litSun.specularColor;
#elif defined(_VGET_ENV_ONLY)
	color = litEnv.diffuseColor * mat.albedoColor + litEnv.reflectColor;
#elif defined(_VGET_POINTLIGHT_ONLY)
	color = litSubLights.diffuseColor * mat.albedoColor + litSubLights.specularColor;
#elif defined(_VSUN_LIGHT_COLOR) || defined(_VSUN_LIGHT_ILLUMINANCE) || defined(_VSUN_SHADE_LAMBERT) || defined(_VSUN_SHADE_SPECULAR) || defined(_VSUN_SHADE_SPEC_DGF) || defined(_VSUN_SHADE_SPEC_D) || defined(_VSUN_SHADE_SPEC_G) || defined(_VSUN_SHADE_SPEC_F)
	color = litSun.testValue;
#elif defined(_VENV_LIGHT_ILLUMINANCE) || defined(_VENV_SHADE_LAMBERT) || defined(_VENV_SHADE_REFLECTION) || defined(_VMAT_SPECULAR_COLOR)
	color = litEnv.testValue;
#elif defined(_VPOINT_LIGHT_COLOR) || defined(_VPOINT_LIGHT_ILLUMINANCE) || defined(_VPOINT_SHADE_LAMBERT) || defined(_VPOINT_SHADE_SPECULAR) || defined(_VPOINT_SHADE_SPEC_DGF) || defined(_VPOINT_SHADE_SPEC_D) || defined(_VPOINT_SHADE_SPEC_G) || defined(_VPOINT_SHADE_SPEC_F)
	color = litSubLights.testValue;
#endif

#ifdef _CHECKVALUE
	color = CheckColorValue(color, _ChkTargetValue, _ChkTargetScale, _ChkRange);
#endif
	return float4(color, alpha);
}

#endif //SEGATB_FORWARD
// ---------------------------------------------------------------------------
//
#ifdef SEGATB_SHADOWCASTER

struct VertexInput
{
    float4 posOS    : POSITION;
    float3 normalOS : NORMAL;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct VertexOutput
{
    float4 posCS : SV_POSITION;
};

// ------------------------------------------------------------------
VertexOutput DepthOnlyVertex(VertexInput input)
{
    VertexOutput output;
    UNITY_SETUP_INSTANCE_ID(input);

    float4 posWS = mul(UNITY_MATRIX_M, float4(input.posOS.xyz, 1.0));

    if (unity_LightShadowBias.z != 0.0)
    {
        float3 normalWS   = normalize(mul((float3x3) UNITY_MATRIX_M, input.normalOS));
        float3 lightDirWS = normalize(_WorldSpaceLightPos0.xyz - posWS.xyz * _WorldSpaceLightPos0.w);
        float  shadowCos  = dot(normalWS, lightDirWS);
        float  shadowSine = sqrt(1 - shadowCos * shadowCos);
        float  normalBias = unity_LightShadowBias.z * shadowSine;
        posWS.xyz        -= normalWS * normalBias;
    }

    output.posCS = mul(UNITY_MATRIX_VP, posWS);

    if (unity_LightShadowBias.y != 0.0)
    {
#ifdef UNITY_REVERSED_Z
		output.posCS.z += max(-1, min(unity_LightShadowBias.x / output.posCS.w, 0));
		output.posCS.z  = min(output.posCS.z, output.posCS.w * UNITY_NEAR_CLIP_VALUE);
#else
        output.posCS.z += saturate(unity_LightShadowBias.x / output.posCS.w);
        output.posCS.z  = max(output.posCS.z, output.posCS.w * UNITY_NEAR_CLIP_VALUE);
#endif
    }
    return output;
}

half4 DepthOnlyFragment(VertexOutput input) : SV_TARGET
{
    return 0;
}

#endif //SEGATB_SHADOWCASTER
// ---------------------------------------------------------------------------
//
#ifdef SEGATB_META

float4 _AlbedoColor;
float  _Metallic, _EmitIntensity;
float  unity_OneOverOutputBoost;
float  unity_MaxOutputValue;
float  unity_UseLinearSpace;

CBUFFER_START(UnityMetaPass)
	bool4 unity_MetaVertexControl;	 // x = use uv1 as raster position	// y = use uv2 as raster position
	bool4 unity_MetaFragmentControl; // x = return albedo				// y = return normal
CBUFFER_END

TEXTURE2D(_MainTex);            SAMPLER(sampler_MainTex);
TEXTURE2D(_MetallicGlossMap);	SAMPLER(sampler_MetallicGlossMap);

// ------------------------------------------------------------------
struct VertexInput
{
    float4 posOS : POSITION;
    float2 uv0   : TEXCOORD0;
    float2 uvLM  : TEXCOORD1;
    float2 uvDLM : TEXCOORD2;
};
struct VertexOutput
{
    float4 posCS : SV_POSITION;
    float4 uv    : TEXCOORD0;
};
struct MaterialSTB
{
    float3 albedoColor;
    float3 emitColor;
};

// ------------------------------------------------------------------
MaterialSTB GetMaterial(float2 uv)
{
    MaterialSTB output;
    half4 colParams = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
    half4 matParams = SAMPLE_TEXTURE2D(_MetallicGlossMap, sampler_MetallicGlossMap, uv);
    float4 matColor = _AlbedoColor;
    float metallic = _Metallic;
    float emmision = _EmitIntensity;
#ifdef _COLMAP
    matColor   *= colParams;
#endif
#ifdef _METMAP
    metallic   *= matParams.x;
#endif

#if !defined(EDITOR_VISUALIZATION)
	output.albedoColor = matColor.rgb *( 1.0 - metallic *0.5)  *( 0.5 + matColor.a *0.5) ;
#else
	output.albedoColor = matColor;
#endif

    output.emitColor = matColor.rgb * emmision;
    return output;
}

// ------------------------------------------------------------------
VertexOutput MetaVertex(VertexInput input)
{
    VertexOutput output;

    float3 posTXS = input.posOS.xyz;
    if (unity_MetaVertexControl.x)
    {
        posTXS.xy = input.uvLM * unity_LightmapST.xy + unity_LightmapST.zw;
        posTXS.z  = posTXS.z > 0 ? REAL_MIN : 0.0f;
    }
    if (unity_MetaVertexControl.y)
    {
        posTXS.xy = input.uvDLM * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw;
        posTXS.z = posTXS.z > 0 ? REAL_MIN : 0.0f;
    }
    output.posCS = mul(UNITY_MATRIX_VP, float4(posTXS, 1.0));

    output.uv.xy = input.uv0.xy;
    output.uv.zw = 0;
    return output;
}

half4 MetaFragment(VertexOutput input) : SV_TARGET
{
    half4 color = 0;
    float2 uv = input.uv.xy;

    MaterialSTB mat = GetMaterial(uv);

    if (unity_MetaFragmentControl.x)
    {
        color = half4(mat.albedoColor, 1.0);    
        unity_OneOverOutputBoost = saturate(unity_OneOverOutputBoost);	// d3d9 shader compiler doesn't like NaNs and infinity.   
        color.rgb = clamp(PositivePow(color.rgb, unity_OneOverOutputBoost), 0, unity_MaxOutputValue);	// Apply Albedo Boost from LightmapSettings.
    }
    if (unity_MetaFragmentControl.y)
    {
        color = half4(mat.emitColor, 1.0);
    }
    return color;
}

#endif //SEGATB_META
// ---------------------------------------------------------------------------
// ------------------------------------------------------------------------------------
#endif //SEGATB_CHS_INCLUDED

litChk.shader

Shader "SegaTechBlog/lightingChecker" {
	Properties {
		[Header(__ Material Params __________)][Space(5)]
		_AlbedoColor ("Color",      Color)            = (0.4663, 0.4663, 0.4663, 1)
		_Metallic    ("Metallic",   Range(0.0, 1.0))  = 0.0
		_Anisotropy	 ("Anisotropy", Range(-1.0, 1.0)) = 0.0
		_Smoothness  ("Smoothness", Range(0.0, 1.0))  = 0.5	[Space(15)]
		[Toggle(_COLMAP)]   _UseColorMap      ("@ Color Map",                    Float) = 1
		[NoScaleOffset]     _MainTex          ("Color(RGB), Alpha(A)",            2D)    = "white" {}
		[Toggle(_METMAP)]   _UseMetMap        ("@ Mat Map:Metallic",             Float) = 1
		[Toggle(_OCCMAP)]   _UseOccMap        ("@ Mat Map:Occlusion",            Float) = 1
		[Toggle(_SMTMAP)]   _UseSmtMap        ("@ Mat Map:Smoothness",           Float) = 1
		[NoScaleOffset]     _MetallicGlossMap ("Metal(R), Occlude(G), Smooth(A)", 2D)    = "white" {}
		[Toggle(_NORMALMAP)]_UseNormalMap     ("@ Normal Map",                   Float) = 0
		[NoScaleOffset]     _NormalMap        ("Tangent Normal(RGB)",             2D)    = "bump" {}
		[Header(__ View One Element ___________)][Space(5)]
		          [KeywordEnum(_,COLOR,DIFFUSE_COLOR,SPECULAR_COLOR,METALLIC,SMOOTHNESS,OCCLUSION)]_VMAT("> View Material Element", Float) = 0
		[Space(5)][KeywordEnum(_,LIGHT_COLOR,LIGHT_ILLUMINANCE,SHADE_LAMBERT,SHADE_SPECULAR,SHADE_SPEC_DGF,SHADE_SPEC_D,SHADE_SPEC_G,SHADE_SPEC_F)]_VSUN("> View Sun Light Element", Float) = 0
		[Space(5)][KeywordEnum(_,LIGHT_ILLUMINANCE,SHADE_LAMBERT,SHADE_REFLECTION)]_VENV("> View Environment Light Element", Float) = 0
		[Space(5)][KeywordEnum(_,LIGHT_COLOR,LIGHT_ILLUMINANCE,SHADE_LAMBERT,SHADE_SPECULAR,SHADE_SPEC_DGF,SHADE_SPEC_D,SHADE_SPEC_G,SHADE_SPEC_F)]_VPOINT("> View Sub Light Element", Float) = 0
		[Space(5)][KeywordEnum(_,TOTAL_ILLUMINANCE,TOTAL_REFLECTION)]_VGET("> View Total Light Amount", Float) = 0
		[Space(15)]
		[Header(__ Measure The Value __________)][Space(5)]
		[Toggle(_CHECKVALUE)]_CheckValue("> Measure The Output Value", Float) = 0
		[Space(5)]_ChkTargetValue(" ORANGE-GREEN-BLUE", Range(-0.1, 5.0)) = 0.1842
		[Enum(x0.01,0.01, x0.1,0.1, x1,1.0, x10,10.0, x100,100.0, x1000,1000.0, x10000,10000.0)]_ChkTargetScale("    (Higher - Hit - Lower)", Range( 0.001, 1000.0)) = 1.0
		[Space(8)][PowerSlider(2.0)]_ChkRange(" Tolerance", Range(0.0032, 10.0)) = 0.045
		[Space(30)]
		[Header(__ Other Options ____________)][Space(5)]
		[Toggle(_NOPIDIV)]_NoPiDiv("No INV_PI as UnityStandard", Float) = 0
	}
	SubShader {
		Tags { "RenderType"="Opaque" }
		LOD 100
		Pass {
			Name "FORWARD"
			Tags{ "LightMode" = "ForwardBase"}
			ZWrite On
			Blend One Zero
			BlendOp Add
			HLSLPROGRAM
			#pragma target 3.5
			#pragma multi_compile_instancing
			#pragma instancing_options assumeuniformscaling
			#pragma multi_compile _ VERTEXLIGHT_ON
			#pragma shader_feature DIRECTIONAL
			#pragma shader_feature SHADOWS_SCREEN
			#pragma multi_compile _ LIGHTPROBE_SH DIRLIGHTMAP_COMBINED
			#pragma multi_compile _ UNITY_USE_NATIVE_HDR UNITY_LIGHTMAP_RGBM_ENCODING UNITY_LIGHTMAP_DLDR_ENCODING
			#pragma shader_feature DYNAMICLIGHTMAP_ON
			#pragma shader_feature _NOPIDIV
			#pragma shader_feature _COLMAP
			#pragma shader_feature _METMAP
			#pragma shader_feature _OCCMAP
			#pragma shader_feature _SMTMAP
			#pragma shader_feature _NORMALMAP
			#pragma multi_compile _ _VMAT_COLOR _VMAT_DIFFUSE_COLOR _VMAT_SPECULAR_COLOR _VMAT_METALLIC _VMAT_SMOOTHNESS _VMAT_OCCLUSION _VSUN_LIGHT_COLOR _VSUN_LIGHT_ILLUMINANCE _VSUN_SHADE_LAMBERT _VSUN_SHADE_SPECULAR _VSUN_SHADE_SPEC_DGF _VSUN_SHADE_SPEC_D _VSUN_SHADE_SPEC_G _VSUN_SHADE_SPEC_F _VENV_LIGHT_ILLUMINANCE _VENV_SHADE_LAMBERT _VENV_SHADE_REFLECTION _VPOINT_LIGHT_COLOR _VPOINT_LIGHT_ILLUMINANCE _VPOINT_SHADE_LAMBERT _VPOINT_SHADE_SPECULAR _VPOINT_SHADE_SPEC_D _VPOINT_SHADE_SPEC_G _VPOINT_SHADE_SPEC_F _VGET_TOTAL_ILLUMINANCE _VGET_TOTAL_REFLECTION
			#pragma multi_compile _ _VSUN__
			#pragma multi_compile _ _VPOINT__
			#pragma shader_feature _CHECKVALUE
			#pragma vertex   ChsForwardVertex
			#pragma fragment ChsForwardFragment
			#define SEGATB_FORWARD
			#include "litChkLib.hlsl"
			ENDHLSL
		}
		Pass {
			Name "ShadowCaster"
			Tags{"LightMode" = "ShadowCaster"}
			ZWrite On
			ColorMask 0
			HLSLPROGRAM
			#pragma target 3.5
			#pragma multi_compile_instancing
			#pragma instancing_options assumeuniformscaling
			#pragma vertex   DepthOnlyVertex
			#pragma fragment DepthOnlyFragment
			#define SEGATB_SHADOWCASTER
			#include "litChkLib.hlsl"
			ENDHLSL
		}
		Pass {
			Name "META"
			Tags{"LightMode" = "Meta"}
			Cull Off
			HLSLPROGRAM
			#pragma shader_feature _COLMAP
			#pragma shader_feature _METMAP
			#pragma shader_feature EDITOR_VISUALIZATION
			#pragma vertex   MetaVertex
			#pragma fragment MetaFragment
			#define SEGATB_META
			#include "litChkLib.hlsl"
			ENDHLSL
		}
	}
}


 f:id:sgtech:20190422122809g:plain



マテリアルカラーの測定

さっそく、スフィアを置いて、ライティング検証用マテリアルを適用してみます。

マテリアルを作る

  1. シーンに、プリミティブのスフィアを置く GameObject > 3D Object > Sphere
    f:id:sgtech:20190422122847g:plain
  2. Assetsフォルダに、マテリアルを新規作成する  Assets > Create > Material
    f:id:sgtech:20190422122845g:plain
  3. マテリアルのシェーダーを、SegaTechBlog / lightingChecker に切り替える
    f:id:sgtech:20190422122843g:plain
  4. スフィアに、作成したマテリアルを割り当てる
    f:id:sgtech:20190422122841g:plainf:id:sgtech:20190422122839g:plain


マテリアルの デフォルトカラーを見てみると、グレー 119[sRGB] となっています。 この色は、印刷物や塗装に
おける 一般的な指標である、CIE L*a*b* ミドルグレー:反射率 18.42[%] に相当します。
*2
f:id:sgtech:20190422122917j:plain
このカラーが 正しい反射率になっていることを、マテリアルの 出力値測定機能を使って確認してみましょう。

マテリアルカラーを測定する

  1. マテリアルの View Material Elementプルダウンメニューから COLOR を選択
    > マテリアルカラーが単色で出力表示されます。
    f:id:sgtech:20190422122915g:plainf:id:sgtech:20190422122913g:plain
  2. Measure The Output Valueチェックボックスを オン
    f:id:sgtech:20190422122910g:plain
  3. スライダーを動かして、緑色に光るところを探す
    > 緑色になったとき、そのスライダーの値が、現在のマテリアル出力値となります。
    f:id:sgtech:20190422122908g:plainf:id:sgtech:20190422123005g:plain
    測定した結果は おおよそ0.46、反射率46[%]となってしまいました。
    これは、Unityプロジェクトのカラースペースが、初期設定では Gammaになっているからです。

    プロジェクトがGamma設定のとき、Unityは 色空間をコントロールしません。カラーの入力は sRGB(ガンマ2.2)ですが、これを そのままライティング計算に使い、ガンマ2.2用モニターに そのままの値を出力します。 結果として、入力した色は そのまま表示されるのですが、ライティング計算が 誤ったGamma色空間で行われることになるので、…なんだか…こう…濃くてギラッとしたライティング結果になりがちです。



プロジェクトのカラースペースを、Linearに切り替えます。

カラースペースを変更する

  1. プロジェクト設定ウィンドウを開く Edit> Project Settings
  2. Playerタブを開き、カラースペースのプルダウンメニューからLinearを選択
    f:id:sgtech:20190422123003g:plain
  3. 再び、マテリアルの 計測スライダーを動かして、緑色に光るところを探すf:id:sgtech:20190422123001g:plain
    今度の測定結果は 期待どおり、 おおよそ0.18、反射率18[%]になりました。
    プロジェクトがLinear設定のとき、Unityは 色空間をコントロールして、ライティング計算をLinear色空間で行います。sRGB(ガンマ2.2)カラーの入力を リニアカラーに変換し、これをライティング計算に使います。最後に、ライティング結果を リニアカラーからsRGBカラーに戻した値を、ガンマ2.2用モニターに出力します。結果として、入力した色は そのまま表示され、かつ、ライティング計算も正しく行われる形になります。 つまり、カラースペース設定変更の前後で、モニターに表示されるマテリアルカラーの見た目は変化しません。シェーダー内部での値と、ライティング結果が 変化します。
     f:id:sgtech:20190422122958g:plain f:id:sgtech:20190422122956g:plain



ライトカラーの測定

このマテリアルで、モデルを照らしているライトのカラーも 表示, 測定することができます。

ライトカラーを計測する

  1. マテリアルの View SunLight Elementプルダウンメニューから LIGHT_COLOR を選択
    > 太陽(Directional Light)のカラーが単色で出力表示されます。
    f:id:sgtech:20190422123052g:plainf:id:sgtech:20190422123050g:plain
  2. ふたたび Measure The Output Valueをオンにし、スライダーを動かして、緑色に光るところを探す
    > 測定結果は おおよそ0.9になりました。
    f:id:sgtech:20190422123048g:plain

    Directional Lightオブジェクトを選択してみると、Intensityが1、カラーに少し黄色が入っていて 、
    …だいたい0.9、合ってる!という感じがします。
    f:id:sgtech:20190422123046g:plain
    もっと 大きな値も入れてみましょう。

  3. Directional LightのIntensityを 2 に上げるf:id:sgtech:20190422123044g:plain
  4. Sphereを選択し、マテリアルの測定スライダーを動かして、緑色に光るところを探す
    > 測定結果は おおよそ 4.14 となりました。1.8になるはずが…。
    f:id:sgtech:20190422123134g:plain

    これは、ライトのIntensityがsRGB(ガンマ2.2)値として扱われているからです。ライトのColorは色なのでsRGB扱いで良いですが、Intensity(ライトの強さ)や Indirect Multiplier(ライトマップを焼くときの 強さ補正値) にガンマが掛かってしまうのは、PBRライティング環境を設定するうえで 邪魔になります。
    この仕様、Unityとしては、Gamma設定のプロジェクトとの整合性を狙った仕様なのかもしれません。 ほかにも、HDRIイメージを天球に貼るときに使う Skybox/Cubemapマテリアルの Exposure値が、同様の仕様になっています。



Project SettingsのGraphicsタブに、ライト強度をリニア値として扱うオプション設定が存在します。
が、GUI上には表示されていないので、スクリプトを使って切り替えます。*3

ライト強度のカラースペースを変更する

  1. メニューから SegaTechBlog > LightsIntensity > Linear を選択f:id:sgtech:20190422123131g:plain
  2. マテリアルの測定スライダーを動かして、緑色に光るところを探す
    > 今度の測定結果は 期待どおり、 おおよそ1.8になりました。
    f:id:sgtech:20190422123129g:plain



現実世界では、ライトに照らされたとき モデル全体にライトカラーの光が届く訳ではなく、ライトに正面を向いた所は明るく照らされますが、ライトから横に90度向いた面には光が届きません。

ライトから投げつけた光の粒が、正面ほど 多く当たり、横向きの面には あまり当たらない というイメージです。f:id:sgtech:20190422122647g:plain
この、ライトからの光が 面に当たった量のことを、照度(illuminance)といいます。
照度を出力表示してみましょう。

ライトの照度と表面の輝度を比較する

  1. マテリアルの View SunLight Elementプルダウンメニューから LIGHT_ILLUMINANCE を選択
    > 太陽(Directional Light)の照度が出力表示されます。
    f:id:sgtech:20190422123126g:plainf:id:sgtech:20190422123124g:plain
  2. Measure The Output Valueをオン
    > 太陽正面方向の照度が ちょうど1.8(=ライトカラーと同じ値)になっています。
    f:id:sgtech:20190422123210g:plainf:id:sgtech:20190422123208g:plain

    面に当たった光の量が照度。ですが、これが そのまま、面の 見た目の明るさになるわけではありません。

    カメラから見て、その ライトアップされた面が見えている、ということは、面で跳ね返った光がカメラに向かって飛び込んできた、ということになります。
    f:id:sgtech:20190422122637g:plain

    いわゆる完全拡散反射面、まったくスペキュラの無い、どこから見ても おなじ明るさに見える材質があるとすると、面に当たった光を 全方向 (面の真横までくれば見えないので、半球の範囲) に分配して飛ばさなければいけません。
    f:id:sgtech:20190422122642g:plain

    面に入ってきた光が1なのに、 全方向に1ずつ跳ね返す、というのは物理的にありえません。
    面から 全方向に1ずつ跳ね返すためには、約3倍…ちょうど円周率π倍の光が、面に当たる必要があります。
    逆に、面に当たった光が1だと、そこから跳ね返って 特定の方向に進み ちょうどカメラに当たる光の量は 1 / π となります。

    面で跳ね返って飛んできて カメラに当たった(見えた)光の量のことを、その面の輝度(luminance)といいます。 完全拡散面の輝度を出力表示してみましょう。


  3. マテリアルの View SunLight Elementプルダウンメニューから SHADE_LAMBERT を選択
    > 反射率100%, 完全拡散反射面 の輝度が出力表示されます。 …照度表示から 1 / π 暗くなってるだけですが。
    f:id:sgtech:20190422123206g:plainf:id:sgtech:20190422123204g:plain
    UnityのStandardシェーダーでは、この シェーディング時に照度をπで割る処理を省略しています。 旧来シェーダーとの互換性をとった判断と思われます。 明るさ 1 のライトで照らしたのに、白い面が 0.3のグレー にしかならない!というのが 馴染みにくかったのかもしれません。
    こういった仕様の場合、現実的な光の値を使いたい時には 手計算でπで割った数字を入れて対処することで 限定的には対応できます。 この対処方法については、記事のまとめで 改めて触れます。



太陽の明るさを設定する

準備ができたので、ターゲットの屋外ライティング環境に合わせていきましょう。

光学単位を そのまま使うには、日中屋外の数値は ケタが大きすぎるので、1/5000にしてみます。
そうすると、冒頭で提示したターゲット環境テーブルは、以下のような値になります。

太陽に直交した面に 入ってくる光の量(照度) 太陽から 17.0 青空(半球)から 4.0 合計     21.0 [lux/5k]
太陽の傾斜角が66.6度。水平地面に 入ってくる光の量(照度) 太陽から 15.6 青空(半球)から 4.0 合計     19.6 [lux/5k]
この環境に照らされたグレー18.42%地面の明るさ(輝度) 青空 =日陰   0.23 合計 =日向    1.15 [nt/5k]
青空の明るさ(輝度) 平均     1.27 [nt/5k]
白い雲     2.0 [nt/5k]
水平線     1.6 [nt/5k]
青い空     0.8 [nt/5k]
天頂部     0.3 [nt/5k]
カメラの露出補正(相対補正値) -2.975 [EV]




屋外環境のライティングを整えるのに、スフィア1個では さすがに無理があるので、最低限のシーンデータを用意します。 その他、自作のモデルなどあれば インポートして配置してみてください。lightingCheckerマテリアルは Unity Standard仕様のカラーマップ,ノーマルマップ,マテリアルマップを そのまま適用できます。

屋外環境のシーンデータを用意する

  1. プリミティブの箱を置く GameObject > 3D Object > Cube
    f:id:sgtech:20190422123202g:plain
  2. 箱にも スフィアと同じlightingCheckerマテリアルを割り当てるf:id:sgtech:20190422123241g:plain
  3. プリミティブの板を 地面として置く GameObject > 3D Object > Plane
    f:id:sgtech:20190422123239g:plain
  4. lightingCheckerマテリアルを複製して、板に割り当てるf:id:sgtech:20190422123237g:plain
  5. 板をStaticに指定する
    f:id:sgtech:20190422123235g:plain
  6. ライトプローブを置き、地面に埋まらないよう1.2m程度 持ち上げる GameObject > Light > Light Probe Group
    f:id:sgtech:20190422123233g:plain
  7. リフレクションプローブを置き、地面に埋まらないよう1.2m程度 持ち上げる GameObject > Light > Reflection Probe
    f:id:sgtech:20190422123316g:plain


ごく一般的なシーンセットアップになっていると思います。

 Unity初めての方に このシーンデータの説明

  • Staticに指定した板は、動かさない 背景モデル扱いになります。事前にGlobal Illuminationでライトマップが焼かれ、ゲーム中では このライトマップを利用して 環境光からの拡散反射(diffuse)シェーディングが表現されます。
  • それ以外のモデルは、ライトプローブを利用して環境光からの拡散反射(diffuse)シェーディングを表現します。ライトプローブには、事前にGlobal Illuminationによるライティング情報が焼かれて入っています。
  • リフレクションプローブには、事前に環境マップ(HDRのCubeMapテクスチャ)が焼かれて入っています。全てのモデルは、この環境マップを利用して 環境光からの鏡面反射(reflection)シェーディングを表現します。
  • リフレクションプローブを置かなくても天球が鏡面反射に使われますが、それだと地面板が 実モデルの輝度で映り込まないので、リフレクションプローブを置きました。
  • Global Illuminationの事前計算は、デフォルトだと エディタ上では 必要に応じて自動更新されます。自動更新が邪魔になったら、Lightingウィンドウを開いてAuto Generateチェックボックスをオフにすれば 手動更新になります。だいたい皆、邪魔になってきて切りますが、今回のようなライティング環境セットアップ中は オンのままが便利です。
  • メインライトである太陽から直接のライティングは、全てのモデルにおいて、リアルタイムシェーディングで表現されます。




太陽のライトカラーには、 太陽に直交した面に 太陽から 入ってくる光の量(照度)17.0 [lux/5k] を設定します。

太陽のライトカラーを設定する

  1. 箱のマテリアルの、View SunLight Elementプルダウンメニューから LIGHT_COLOR を選択
  2. Measure The Output Valueをオン、スライダーを 1.7 に設定、すぐ下の掛け数を x10 に設定
    > これで、出力値が17( = 1.7x10 )のときに 緑に光る設定になりました。
    f:id:sgtech:20190422123314g:plain
  3. Directional Lightオブジェクトを選択し、モデルが緑に光るまで ライトのIntensityを上げていく
    > 18.67 で緑に光りました。(いちおう検算するとライトカラーの黄色を掛けて ちょうど17になります。*4
    f:id:sgtech:20190422123311g:plainf:id:sgtech:20190422123309g:plain




太陽の角度を66.6度にセットし、
このとき 水平面に 入ってくる光の量(照度)15.6 [lux/5k] になっていることを確認してみましょう。

太陽の向きを設定する

  1. マテリアルの View SunLight Elementプルダウンメニューから LIGHT_ILLUMINANCE を選択f:id:sgtech:20190422123307g:plain
  2. Directional LightのRotationを (90.0, 0, 0) に設定
    > 試しに、太陽の角度を90度にセットしてみました。
     箱の上面が緑になっているので、このライトで真上から照らすと照度17.0であることが再確認できました。

    f:id:sgtech:20190422123348g:plainf:id:sgtech:20190422123346g:plain
  3. Directional LightのRotationを (66.6, 0, 0) に設定
    > 太陽の角度を66.6度に 傾けてセットしました。
    f:id:sgtech:20190422123343g:plain
  4. マテリアルのスライダーを 1.56 に設定
    > 箱の上面が緑になりました。66.6度から照らした時、水平面の照度15.6にできていることが確認できました。
    f:id:sgtech:20190422123341g:plainf:id:sgtech:20190422123339g:plain



空の明るさを設定する

次に、空を調整します。 デフォルトの天球マテリアルは パラメータを 変更出来ないので、Skybox/Proceduralマテリアルを新規作成し、シーンにセットして使います。 Skybox/Proceduralマテリアルに 出力値の測定機能はありませんが、モデルに映り込んだ天球を、 lightingCheckerマテリアルで測定することができます。
ただし Skybox/Proceduralマテリアルには はっきりした白い雲を表示する機能が無いので省略し、 空の平均 1.27 [nt/5k] , 水平線 1.6 [nt/5k] , 青い空 0.8 [nt/5k] , 天頂部 0.3 [nt/5k] の4つの輝度をターゲットに設定します。

空の輝度を設定する

  1. Assetsフォルダに、マテリアルを新規作成する  Assets > Create > Material
  2. 作成したマテリアルのシェーダーを、Skybox / Proceduralに切り替える
    f:id:sgtech:20190422123428g:plain
  3. ライティング設定ウィンドウを開く Window > Rendering > Lighting Settings
  4. Skybox Materialに、作成したSkybox/Proceduralマテリアルを割り当てる
    f:id:sgtech:20190422123425g:plain
  5. lightingCheckerマテリアルを選択し、 View Environment Light Elementプルダウンメニューから SHADE_REFLECTION を選択
    > ボケた天球が映りました。
    f:id:sgtech:20190422123421g:plainf:id:sgtech:20190422123418g:plain
  6. Measure The Output Valueをオン、スライダーを 0.8 に設定、掛け数を x1 に設定
    > これで、出力値0.8の部分が 緑に光る設定になりました。f:id:sgtech:20190422123414g:plain
  7. Smoothnessを動かして、天球の どのあたりが0.8になっているか観察する
    > デフォルト天球は、水平線でも0.7くらいで かなり暗いようです。
    f:id:sgtech:20190422123629g:plain
  8. Skybox/ProceduralマテリアルのExposureの値を上げて、青い空が主に0.8になるよう寄せていく
    > デフォの1.3から6.0まで上げると、青い空0.8周辺、天頂0.4で平均1.2くらいの、ほどよい値になりました。
    f:id:sgtech:20190422123626g:plainf:id:sgtech:20190422123621g:plain

天球全体の輝度をスケールするだけの、すこし雑な調整です。 水平線が 明るめになってしまいました。
Skybox/Proceduralマテリアルの Exposure以外のパラメータを変更すると、大気スキャッタ計算のバランスが変わって、空の色味がズレていくので、天球の輝度調整に使うことは お勧めしません。
白い雲の表現や、水平線付近の減衰(地表からの埃によるフォグ)などについて 細かくバランスを取るには、skyboxシェーダーのカスタマイズをしたり、シーンデータとして遠景モデルを配置していく必要があります。



空の輝度が決まったところで、今度は 水平地面に 青空(半球)から 入ってくる光の量(照度)4.0 [lux/5k] を確認してみましょう。

空の照度を設定する

  1. lightingCheckerマテリアルを選択し、 View Environment Light Elementプルダウンメニューから LIGHT_ILLUMINANCE を選択
    > 環境光からの照度が表示されました。真っ白です。
    f:id:sgtech:20190422123619g:plainf:id:sgtech:20190422123617g:plain
  2. Directional Lightの Indirect Multiplierを 0 に設定
    > 一旦、地面に反射した太陽光の照り返しを消しました。これで 空からの照度だけを計測できます。
    f:id:sgtech:20190422123701g:plainf:id:sgtech:20190422123659g:plain
  3. Measure The Output Valueをオン、スライダーを 4.0 に設定
    > これで、出力値が4のときに 緑に光る設定になりました。
    f:id:sgtech:20190422123657g:plain
  4. ライティング設定ウィンドウを開く Window > Rendering > Lighting Settings
  5. Environment Lighting > Intensity Multiplierの値を変えて、箱の上面が緑になる値を探す
    > 1.151 で、ちょうど 箱の上面が緑になりました。これで 空(半球)からの照度を4.0に設定できました。
    f:id:sgtech:20190422123738g:plainf:id:sgtech:20190422123655g:plain

Unityにおける 空からの環境照明は、鏡面反射(環境マップ)と拡散反射(ライトマップやライトプローブ)が 別々の仕組みで提供されています。この為、空の輝度をピッタリ決めたから 拡散反射の照度も自動で正しい値になる、という風には なかなかいきません。手動で微調整が必要です。




地面照り返しの明るさを設定する

最後に、さきほど一旦0にした、地面で反射した太陽光の照り返し の照度を調整しましょう。 ターゲット環境に この照度の値は無いので、ざっくり算出します。 このライティング環境に照らされたグレー18.42%地面の、日向での明るさ(輝度)1.15 [nt/5k] なので、これが完全拡散反射面として 下一面に広がっていた場合、下半球からの照度は 1.15 x π = 3.613 [lux/5k] となります。 この値に合わせてみましょう。

地面照り返しの照度を設定する

  1. 箱ではなく、地面板の方の lightingCheckerマテリアルを選択し、 View Total Light Amountプルダウンメニューから TOTAL_ILLUMINANCE を選択
    > 地面への、太陽や空からの照度の合計が出力表示されました。
    f:id:sgtech:20190422123735g:plain
  2. Measure The Output Valueをオン、スライダーを動かして、緑色に光るところを探す
    > 地面への照度は19.6( = 1.96x10 )。ターゲット環境の、 太陽の傾斜角が66.6度。水平地面に 入ってくる光の量(照度)合計 19.6 [lux/5k] に 一致していることが確認できました。
    f:id:sgtech:20190422123731g:plainf:id:sgtech:20190422123733g:plain
  3. こんどは 箱のほうの lightingCheckerマテリアルを選択し、 View Environment Light Elementプルダウンメニューから LIGHT_ILLUMINANCE を選択
  4. Measure The Output Valueをオン、スライダーを 3.613 に設定f:id:sgtech:20190422123728g:plain
  5. 箱を下から見上げながら、Directional Lightの Indirect Multiplierを上げていき、箱の下面が緑になる値を探す
    > 0.32 で、ちょうど 箱の下面が緑になりました。これで 地面照り返しの照度を 3.613に設定できました。
    f:id:sgtech:20190422123821g:plainf:id:sgtech:20190422123819g:plain


カメラの露出を設定する

ライティング設定は完了しましたが、画面が ほぼ真っ白です。1/5000していても、まだ、日中の屋外は眩しすぎます。 Unityの標準カメラには HDRの露出補正機能が無いので、ポストプロセスエフェクトを使って、カメラを適正露出に補正しましょう。

Post-Processingのセットアップ

  1. Assetsフォルダに、Post-processing Profileを新規作成する  Assets > Create > Post-processing Profile
    f:id:sgtech:20190422123817g:plain
  2. シーンに、Emptyを置く GameObject > Create Empty
    f:id:sgtech:20190422123815g:plain
  3. Emptyに、ポスプロ設定保持用コンポーネントを追加  Add Component > Rendering > Post-process Volume
    f:id:sgtech:20190422123813g:plain
  4. Post Process VolumeコンポーネントのProfile欄に、さっき作ったPost-processing Profileをセット
    > これで、カメラが このEmptyに近づくと セットしたポスプロ設定が使われるようになりました。*5
    f:id:sgtech:20190422123901g:plain
  5. Post Process Volumeコンポーネントの Is Globalをオン
    > これでカメラとEmptyの位置に関わらず、シーン内では常に このポスプロ設定が使われるようになりました。
    f:id:sgtech:20190422123859g:plain
  6. Emptyを、PostProcessingレイヤーに所属させるf:id:sgtech:20190422123856g:plain
  7. Main Cameraに、ポスプロ設定取得用コンポーネントを追加  Add Component > Rendering > Post-process Layer
    f:id:sgtech:20190422123854g:plain
  8. Post Process LayerコンポーネントのLayer欄に、PostProcessingレイヤーを指定
    > これで、PostProcessingレイヤーに置かれたPost Process Volumeを カメラが取得するようになりました。
    f:id:sgtech:20190422123852g:plain



手動で露出補正する

  1. Post-processing Profileに、Color Gradingエフェクトを追加  Add Effect > Unity > Color Grading
    f:id:sgtech:20190422123948g:plain
  2. Color Gradingの Post-exposureを -2.975 に設定
    > ターゲット環境の カメラの露出設定(相対露出補正値)-2.975 [EV] を設定し、無事に グレーな地面をグレーに表示することができました!
    f:id:sgtech:20190422123945g:plainf:id:sgtech:20190422123943g:plain



…これで、フォトリアルな質感や 屋外のライティングを 表現できるようになったのか、少し試してみましょう。

マテリアルにバリエーションを出してみる

  1. スフィアを複製し、新しいマテリアルを割り当てて、ゴールド のパラメータを設定f:id:sgtech:20190422123939g:plain
  2. 白いモルタル のパラメータを設定
    f:id:sgtech:20190422124101g:plain
  3. 半渇きの土 のパラメータを設定
    f:id:sgtech:20190422124059g:plain
  4. 草 のパラメータを設定
    f:id:sgtech:20190422124057g:plainf:id:sgtech:20190422124055j:plain



悪くありませんが、ゴールドのハイライトの 色の飛び方が下品です。カラーグレーディングにACES色空間を使うことで、高輝度成分を上品に表現した色調調整が やり易くなります。

カラーグレーディングにACESを利用する

  1. Color Gradingの Modeを ACES に設定
    > 高輝度部分のコントラストが柔らかくなり、上品な質感になりました。
    f:id:sgtech:20190422124053g:plainf:id:sgtech:20190422124139j:plain
  2. Post-processing Profileに、Bloomエフェクトを追加。Intensityを2に設定
    > ついでにブルームを載せたところ…、画面全体が ぼんやり光ってしまいました!
    f:id:sgtech:20190422124137g:plainf:id:sgtech:20190422124135j:plain



実は、Color Gradingの Post-exposureはフィルム現像段階での露出補正を模したもので、撮影時のカメラによる露出補正ではありません。ブルームやDoFエフェクトはレンズで起きる現象を模したものなので、露出補正前の輝度(ほぼ白く飛んでいる)に対して エフェクトが掛けられており、その結果 画面全体がブルームしています。
撮影段階で 適切に露出補正するには、Auto Exposureエフェクトを使います。

オートで露出補正する

  1. Color Gradingの Post-exposureをオフにするf:id:sgtech:20190422124132g:plain
  2. Post-processing Profileに、Auto Exposureエフェクトを追加。Minimumを -6、Maximumを 6、Exposure Compensationを 0.4 に設定
    > Min,Maxには オート露出の 露出補正可動範囲を設定します。Exposure Compensationには、画面内の輝度の平均値を、どれくらいの明るさに変換して表示したいかを 設定します。
    f:id:sgtech:20190422124127g:plainf:id:sgtech:20190422124221j:plain



撮影段階で適切に露出補正が行われ、ブルームは 高輝度部分にだけ発生するようになりました。
ターゲット環境で設定していた露出補正値(EV100準拠の絶対露出補正値 EV15 = 1/5000単位での輝度値に対する相対露出補正値 -2.975EV)は、事前に求めた 撮影対象の平均的輝度値を おおよそ0.1として表示するように設定(その結果 グレイ18%が 約0.18で表示される事が期待)されていますが、このAuto Exposureフィルタでは、動的に 現在の画面の平均輝度が求められ、その値が0.4として表示されるようなスケール値が 画面全体にかけられます。
結果として、画面に明るいものが多く映るほど、露出は絞られ 暗い所が より暗く表示されるようになります。

f:id:sgtech:20190422124215g:plainf:id:sgtech:20190422124210g:plain




まとめ

つくったライティング環境の活用

お疲れ様でした。ライティング環境のセットアップは 以上です。つくったライティング環境の活用方法ですが、まずは 主要なアセットを一ステージ分くらい用意して、仮組みしてみるのが良いと思います。
f:id:sgtech:20190422124231j:plain

アセットが増えて 質感のバリエーションがでてくると、画面のコントラストを作るには カメラやライティングに どんなバリエーションが必要なのかが、はっきりしてきます。そして、プロジェクト内で 質感の違いを どう表現してゆくか、またカラーコレクションの方向性なども、順を追って 絵の仕様を決めていける段階になります。


最終的なゲーム画面、コンセプトアートを実現していく中では、独自表現のマテリアルを追加することがあります。
f:id:sgtech:20190422122623g:plain
ゲーム的に必要性が高ければ、天球に雲を加えたり、大気フォグ表現についても 独自で追加する必要があります。
f:id:sgtech:20190422124207j:plainf:id:sgtech:20190422124204j:plain
こういった拡張を行った際は、ぜひ拡張後に もう一度ライティング環境のテストを行ってください。
基準となる 輝度や照度の比率を、コントロールし続けることが大切です。



今回のセットアップは PBRとして素直な値を設定しましたが、実際のUnityの描画フローには 多くの種類があり、それぞれに特性があります。とても全容は書ききれませんが、いくつか 代表的なものについて、対応をリストしておきます。

Standard

  • ライト直接光によるシェーディング輝度はπ倍明るい。
    • この環境でフィジカルな光単位を扱う場合、手動でライトintensityに1/πした値を入れる必要がある。
    • lightingCheckerマテリアルのNo INV_PI as UnityStandardをオンにすれば、この仕様に沿った値を確認できる。
    • 厳密には正しくない対応である(ライト強度を手動で暗くしても、2次反射以降のシェーディング輝度が1/πされる訳ではない)ことに注意しつつ、仕様として飲み込む方向で。
  • (初期設定では)ライトの強度にガンマがかかっている。
  • ポイントライトの強度減衰がPBR準拠(距離自乗減衰)ではない。
    • これも No INV_PI as UnityStandardをオン,オフして仕様の違いを確認できます。
  • シェーディングモデルは、ローエンド機種でなければPBR準拠。


LWRP

  • まだPreview版を抜けたばかりで、仕様が一般化されていない。
  • ライト直接光によるシェーディング輝度はπ倍明るい。
  • ライトの強度はリニア値。
  • ポイントライトの強度減衰はPBR準拠(距離自乗減衰)。
  • シェーディングモデルは、ローエンド機種でなければPBR準拠。


HDRP

  • まだPreview版で、仕様が確定していない。
  • ライト直接光によるシェーディング輝度は正しい。
  • ライトの強度はリニア値で、光学単位の 大きな数値を そのまま入力できる(内部的には EV100単位のpre-exposure値を算出して事前にかけることで 値の爆発を避けている)。
  • ポイントライトの強度減衰はPBR準拠(距離自乗減衰)。
  • ポイントライトの 光源サイズを規定したり、ライン光源, 面光源を利用できる(距離自乗減衰ではなく エリアライトの減衰カーブが扱える)。
  • ポスプロは独自仕様となる(PostProcessing V3に相当)。
  • シェーディングモデルはPBR準拠、かつ SSS,Aniso,ClearCoat表現が追加されてDisney BSDF相当に近づいた。*6


Gammaワークフロー全般

  • おおむね2Dのゲームだから Photoshopと レイヤー合成の見た目を合わせてくれ、というケース。わかります!
  • とはいえ このワークフロー、実質 カラースペース管理の放棄なので、PBRライティングとの共存は 無理です。
  • もしコストが割けるのであれば、Linearワークフロー内に Gammaワークフロー的な部分を作ることは可能です。




ターゲット ライティング環境テーブルを 自分で用意してみたい時は、以下のメモを参考にしてください。

ライティング環境の求め方

屋外の場合

・まず、太陽と空からの照度を計測します。シンプルな手法が Moving Frostbite to Physically based renderingに載っています。

Measurements were taken at ground level with the light meter’s sensor angled horizontally or perpendicular to the sun (⊥ index). Measurements were performed at various hours of a sunny day with few clouds, in Stockholm in July. The sky values were obtained by occluding the sun with a small object (i.e. casting a shadow on the sensor).

https://seblagarde.wordpress.com/2015/07/14/siggraph-2014-moving-frostbite-to-physically-based-rendering/
  1. 照度計を使い、太陽正面向きの照度を計測 = A [lux]
  2. 太陽が直射する方向だけ 小さなもので隠して(=照度センサーを影にいれて)、空の照度を計測 = B [lux]
  3. 太陽からの照度 C = (A-B) [lux]

個人的に おすすめの照度計は セコニックのL-478Dです。オプションのビューファインダーを付ければ輝度計にもなって便利です。2台欲しい!

・太陽の傾斜角は、場所と時間に基づいて 算出してくれるサイトが いくつもあります。 = Θ

・グレー18%完全拡散反射面である水平地面の輝度、日向は、0.1842 * ( C * cos(Θ) + B ) / π [nt]
・ 同 日陰の輝度は、0.1842 * B / π [nt]
・空の輝度の平均は、B  / π [nt]

・空の細かいところの輝度は、輝度計で計測します。雲や空の輝度は、環境によって かなり大きい振れ幅で変化します。なるべく細かく計測して、ならした値を使いましょう。 こちらの PV Lighthouse - ALBEDO 太陽光発電に関するレクチャーでは、地面の色が いかに空の輝度に影響を与えるかが説明されています。地面に雪が積もると 照り返しで空が2倍明るくなって、太陽光発電にも貢献するそうです!面白いですね。

・その他、標準反射板を持っていれば、輝度計で計測しておくことで、グレー地面輝度の検算が可能になりそうです。

屋内の場合

・屋内のライティングは、もっと複雑な照明設計になってくるので、難しいところです。
・とりあえず、1/5000 lumen単位だと 数字が小さすぎて扱いにくいです。一般的な室内で300[lux] = 0.06[lux/5k]など。間をとるか、場所で切り分けるか、HDRPのような動的光学単位が必要になります。
・実在の部屋や照明器具を 計測, HDR撮影して 寄せていくのが、比較的 現実的な正攻法になるかと思います。
・屋内ライティングは、グレー18%よりも高い反射率を 部屋の標準マテリアルにして 整える必要があります。現代の建物は、手元の照度や 空間の照度、床と壁のコントラストを 省電力で実現する為に、比較的 高反射率の素材を使っている傾向があります。 Panasonic - P.L.A.M. - 各種材料反射率の表で屋内向けの建材や塗装を見てみると、自然物や屋外資材と比べて 高めの反射率が並んでいます。 この場合も、露出補正用のターゲットにはグレー18%を使います。

露出補正値

・EV100の値は、照度計で計測することができます。また、Google FilamentのPBRガイドには、ターゲットの輝度や フラット面への照度、半球への照度から EV100単位の露出補正値を算出する方法が載っています。

自前のシェーダーに測定機能を付けたい

・今回のシェーダー、litChkLib.hlslに入っているCheckColorValue関数を ご覧ください。シンプルな仕組みです。
・Core RP Library使いやすそう!と思った方には、Catlike Coding - Scriptable Render Pipelineチュートリアルが お勧めです。


みなさんも一緒に働きませんか?

長文、読んでくださってありがとうございました。
この記事で 興味を持たれた方、セガ・インタラクティブで私たちと一緒に働いてみませんか?

アーケードゲーム開発は、ハイエンドPC相当の特定GPU構成、個性的な筐体をターゲットに、中小規模ゲーム開発が楽しめる、グラフィックDev好きには なかなか たまらない環境です!
採用情報については、以下のリンクを、是非ご確認ください。
sega-games.co.jp


©SEGA

*1:Unity2019.1では Global Illminationの初期設定が変更された為、環境ライティング系の調整結果が Blogと異なる値に落ち着く。
また、Unity2018.2以前のバージョンでは、シェーダー内の Core RP Libraryへのパスを書き換える必要がある。

*2:Gamma Color119 / 255 \fallingdotseq 0.46 , Gamma Color^{2.2} \fallingdotseq Linear Color なので 0.46^{2.2} \fallingdotseq 0.18
これは完全白色が反射率100%であるという仮定下でのL*a*b*ミドルグレーとなる。

*3:https://docs.unity3d.com/2018.3/Documentation/ScriptReference/Rendering.GraphicsSettings-lightsUseLinearIntensity.html

*4:ライトのデフォルト黄色はGamma Color(255, 244, 214)/255 これは Linear Color(1.0, 0.9074, 0.6800)
 , これに輝度変換係数の(0.2126729, 0.7151522, 0.072175)を掛けて足しあわせ 黄色の輝度は0.911
 ,  18.67*0.911 \fallingdotseq 17.0

*5:カメラが近づいたと見なす距離は、EmptyにBox Colliderを追加して設定する。

*6:Disney BSDFについてはSiggraph2012 - Practical physically-based shading in film and game productionのPhysically Based Shading at Disney とSiggraph2015 - Physically based shading in theory and practiceのExtending the Disney BRDF to a BSDF with Integrated Subsurface Scattering。また 同名やPrincipled BSDFで検索すると 様々な粒度の情報が得られる。

ゲーム・アニメーション創りは面白い!

皆さん、はじめまして。

セガ・インタラクティブ 第一研究開発部 デザイン技術セクション テクニカルアーティストの鈴木です。モーションデザイナーとして、アーケードゲームのガンゲームをメインに10年ほど携わった後、テクニカルアーティストとしてモーション作業周りのサポートをしています。最近では「HOUSE OF THE DEAD ~SCARLET DAWN~」のサポートを行いました。また、採用活動業務も担当するようになりました。

ゲーム歴は長く、電子ゲームやファミコンなどに触れて、現在もゲームを楽しんでいます。

はじめに

さて、今回のブログの前置きですが、

  • 「映像アニメーション」に対応して「ゲームアニメーション」、「ゲームアニメーター」としています。
  • このブログでは、ゲームのアニメーションについて、特にゲームならではの表現方法について書いています。
  • ゲーム制作を勉強している、勉強しようとしている、興味がある方に向けた記事となっています。そのためプログラミングや数式、ツールは登場しません。

 

ゲーム制作を勉強している、またはゲーム制作に興味がある皆さんは、ゲーム制作のどの部分に興味があるでしょうか?ゲームデザイン(ゲーム企画)?キャラクターデザイン?アニメーション?表示(メニュー画面のデザインやゲーム中の表示)?背景?エフェクト?それともプログラミングでしょうか?。

 私は昔からゲームをしている中で、ゲームのアニメーションが担っている「ゲームのさわり心地」に興味があり、それをテーマに試行錯誤しています。
 今回は「ゲームアニメーションと触りごこち」に焦点を絞ってブログを書きます。ゲームアニメーションの面白さ、奥深さが伝われば幸いです。

 

ゲームアニメーションとは

「アニメーション」という言葉について、ここでは、カートゥーンや3DCGアニメーションは「映像アニメーション」、ボタンを押すことでキャラクターが動くアニメーションを「ゲームアニメーション」と分けました。

 考え出すと複雑になってしまうので、ゲームアニメーションとは、ここではシンプルに


  • ボタン操作することでリアルタイムに変化するキャラクターアニメーション。
    (ボタン入力の必要ない演出的な動きと分けて考えます)
 
とします。補足として、これらの定義以外はゲームアニメーションではないと述べているのではありません。(ここが「考え出すと複雑になってしまう」ところです)。また、会社としての見解、取り組みを述べたものではなく、私個人の見解です。この点は誤解のないようお願い致します。

ゲームアニメーター(モーションデザイナー)の仕事として求められる役割は、

  • ゲームのアニメーション素材を制作し、実装されたゲームを遊んで確認し、アニメーションを修正。時にはディレクターやプログラマーとも相談してゲームを面白くしていく

です。
 

ゲームアニメーションを創ることの面白さ

長いことガンゲームを創りづつけて、ゲームアニメーションを創る面白さには「映像アニメーションを創る面白さ」と「ゲームアニメーションを創る面白さ」があります。それぞれ分けて考えました。

映像アニメーションを創る面白さ

  • 動きの重量感、しなやかさ、キャラクターの感情を動きだけで表現できる面白さ

    「演技について」考える面白さです。自分が演技しないとしても、役者としての表現追求の奥深さ

  • 時間や言語を超えて伝わる面白さ

    自分の創ったものが良ければいつまで立っても色あせない。違う文化の人にも言葉の壁を超えて伝えることができる。普遍的な魅力。

ゲームアニメーションを創る面白さ

  • ゲームは、ボタン操作しているだけなのに、プレイヤーが重さを感じたり、爽快感を感じたりする瞬間が興味深いです。(「操作感」「さわり心地」と呼ばれるものでしょうか)

    例えば、ガンゲームであれば、ガン型コントローラーのトリガーを引いているだけなのに、弾が飛んでキャラクターに当たった感じがする。アクションゲームであれば、方向キーを押しているだけなのに、移動に加速感が感じられたり、キャラクターの疲れを感じたりする。ボタンを押しただけなのに、モノを斬っている手応えがある。といったことです。改めて考えると、「ボタンを押しただけでゲームによって感覚が変化している」と考えると不思議ですよね?
     さわり心地の、何年経っても変化することなく、文化や言葉の壁を超えて伝わる普遍的な点も魅力的です。例えば、ボタン操作でキャラクターが「重いブロックを押している」アクションをして、それを見たプレイヤーに重さが感じられたら、その感覚は何年経っても変わらないし、どの国の人が遊んでも、その感覚は得られるはずです。
     ゲームを長い期間、何度でも遊んでしまう要素の1つである「さわり心地」が追求できる点、「『ボタンを押して意図した感触が提供できているのか』といった『操作と結果』に踏み入って考える楽しさ」がこの仕事の一番興味深いところです。

  • 作成したゲームアニメーション次第でゲームが難しくなったり、簡単になったり、ゲームそのものの難易度に影響を与える

    例えば、ガンゲームのゾンビ攻撃で、「腕を振ってプレイヤーを攻撃」があるとします。腕を振るために振りかぶります。この振りかぶりのスピードが速すぎれば、プレイヤーはゾンビの攻撃に気づかず、ダメージを受けます。遅すぎれば、ゾンビは撃たれてしまい、ゲームが簡単になります。特にゾンビ攻撃については、できるだけギリギリの難易度になるよう時間をかけて調整します。自分で作成した動きでゲーム難易度に変化を与えているところが面白い点です。
     (HOUSE OF THE DEAD ~SCARLET DAWN~ のアニメーターは3~4人+協力会社さんでの開発です。少人数での開発のため、個人の裁量に任せられているところが大きいので、難易度調整も許されています。アニメーション調整の裁量についてはプロジェクトによって違います。特に家庭用ゲームについては厳密に仕様が決められているものもありますので、学生さんは希望する会社での個人の裁量について質問したほうが良いと思います。)

  • ゲームの仕組みを知ることが面白い
    仕様を「どうやって」実現するかゲームの仕組み(プログラミング)部分にもつながるパートで、ゲームの仕組みについて広く浅く理解する大変さはありますが、仕組みを知る面白さがあります。

もし、学生さんで「自分は映像アニメーターをめざすのか、ゲームアニメーターをめざすのか」を考えるとき、これらの特徴から方向を検討しても良いかもしれませんね。

 

ゲームアニメーションの難しい点

ゲームアニメーションを考える上で難しい点があります。それは「感覚的な部分のため人に伝えづらい」という点です。「伝えづらい」ということは、人から教えてもらうことが難しく、自分で体験して考えていかなければならないということになります。
 実際に製作中のゲームレビューでも、見えにくく伝えづらい操作感や動きの気持ちよさの話になりにくく、見えやすく伝えやすいアートやゲームシステム、ゲーム性に意見が集約しがちです。もちろんこれらも重要です。しかし、先程ゲームアニメーションの魅力で挙げたとおり、時間や文化を超えて伝わる部分であるにもかかわらず、議論にならないのはもったいないと思っています。

感覚的な部分をどう伝えるか課題ですが、海外で映像アニメーション制作している方の講演を聴いたとき、「アニメーションにはアニメーション12原則があり、それを共通言語としてコミュニケーションを取るとリテイク時に伝わりやすい」と紹介されていました。この講演から、表現のエッセンスを体系化して共通言語とすれば、感覚的なことも伝えられるのでは?というヒントをえました。

ちなみに「アニメーション12原則」とはアニメーションを創るための基本的な要素です。言葉で説明するよりも、映像で見るとイメージしやすいのではないでしょうか。

(音が出ます)
vimeo.com

http://the12principles.tumblr.com/post/84175638939/appeal

the12principles.tumblr.com

いかがでしょうか?基本図形が動いているだけなのに、「動きがやわらかい」「生きている感じがする」印象ではないでしょうか。12原則の要素を元にどこを修正すればよくなるのか議論したほうが修正方針が立てやすいと感じました。

 

モノが干渉した表現(当たった感じの表現)と気持ちよさ

ゲームアニメーションの感覚的な部分といっても、「モノが干渉した表現(当たった感じの表現)」「加速感の表現」「ジャンプ表現」などなど、ゲームごとに突き詰める分野が数多くあります。
 今回のブログでは「モノが干渉した表現(当たった感じの表現)」にはどのようなものがあるか考えました。

ゲームにしても、3DCGツール上にしても、現実とは違い、3D空間上では何も設定していないと、モノとモノが交差しても引っかかりもなく、すり抜けてしまいます。
 あなたがゲームを遊んでいて、当たった感じがするのは何らかの「仕掛け」をしているからです。
 当たった感じがする「仕掛け」とは何か?私がゲーム制作の上で学んだことや、ゲームを遊びながら考えたものを、アニメーション原則のような感じで「リアクション原則」としてまとめました。

リアクション原則

カメラシェイク

カメラを細かく振動させるだけの古典的な方法ですが、当たった感じ、モノの重さを伝える効果があります。

ただし、動かしすぎると3D酔いの原因になりるので、加減が難しいところです。また、カメラエフェクトのモーションブラーが入っていると、画面が汚くなってしまうことがあるので、カメラエフェクトやカメラシェイクの調整が必要です。
 ゲームでは、攻撃時やダメージを受けた、爆発、キャラクターが地面に倒れた、キャラクターが壁を蹴って飛び上がった、など使われていることに気づくと思います。

例1.「弾がカメラに当たる → 爆発して消える」

カメラシェイクが無いときは、弾が跳ね返って当たったのはわかりますが、衝撃の物足りなさがあります。
f:id:sgtech:20190325100959g:plain


弾が当たったときと、爆発したときにカメラシェイクを追加しました。衝撃がより伝わった感じがしませんか?(この揺れでもずっと見ていると疲れてしまいますね。実際にプレイして 調整の繰り返しが必要です)
f:id:sgtech:20190325100918g:plain

 

例2.「キャラクターに武器が当たる」

武器がキャラクターに当たっていますが、すり抜けています。
これが

「ゲームにしても、3DCGツール上にしても、3D空間上では何も設定していないとモノとモノが交差しても現実とは違い、引っかかりもなく、すり抜けてしまいます。」

の状態です。
これでは物足りないので、真っ先にエフェクトを付けたくなります。しかし、動きでも解決できます。エフェクトを付けたい気持ちをグッとこらえて、カメラシェイクの効果を入れます。
f:id:sgtech:20190325101122g:plain

武器の当たった感じが増していませんか?
 キャラクターに攻撃が当たったときの揺れは「ダメージを与えた気持ちよさを伝えるために揺らすのか」「ダメージを受けた時の痛さを伝えるために揺らすのか」プレイヤーにどちらの気持ちを与えたいのか考えて、揺れを調整する必要があります。
f:id:sgtech:20190325101031g:plain

例3.「2段ジャンプの踏み切り」

2段ジャンプをするキャラクターがいるとします。しかし、2段めのジャンプが分かりにくかったり、物足りなさがあります。こちらもエフェクトを付けたい気持ちをグッとこらえて、カメラシェイクを入れます。
f:id:sgtech:20190325100846g:plain

「何かに当たったときだけに使うものではない」例でもあります。空中には何もありませんが、空中で踏み切った「重さ」を与えることもできます。
 壁を蹴って飛び上がるときにカメラシェイクをしているゲームもありました。
f:id:sgtech:20190325100816g:plain


ウエイト

モノが当たった瞬間動きを止めて引っかかりを表現します。効果は強力で、ゲームではよく見かける表現です。当たった瞬間に動きが止まるので、プレイヤーに「当たった!」とわからせる効果があります。
 パズルゲームで「絵柄が揃って消える」ときの一瞬の「止め」もこちらの分類に含まれるのではないでしょうか。

この手法もカメラシェイクに注意してゲームを遊んでいると、いろいろなところで使われていることに気づきます。

例.「キャラクターに武器が当たる」

再び武器がキャラクターに当たっているシーンです。
武器がキャラクターに触れたタイミングで武器の動きを止めます。
f:id:sgtech:20190325101735g:plain

こちらのほうが「当たった感じ」が伝わるのではないでしょうか。
ゲームでは、どのタイミングで武器が当たるかわからないので、ウエイト効果はプログラミングで制御します。
f:id:sgtech:20190325101518g:plain


オブジェクトシェイク

「カメラシェイク」ではカメラを動かしましたが、当たった瞬間にモノに対して細かな振動を入れることがあります。2Dゲームではよく見られる手法です。記号的な表現ですが、少ない手間で実現できるので、ゲームでは使われることが多いです。

例.「弾がドラム缶に当たる」

弾が当たっても動かなければ、プレイヤーに背景の一部と捉えられてしまいます。

f:id:sgtech:20190325102240g:plain

現実では重いドラム缶は跳ねる事はありませんが、ゲーム世界観によっては使える手法です。弾に当たって少しでも反応すれば、背景から独立したものである。とプレイヤーに思わせることができます。
 ここではドラム缶を上下に動かしましたが、左右に動かしたほうが良いでしょうか?それとも弾が当たってずれていくのが良いでしょうか?ドラム缶の反応だけでもクリエイティブ力が試されます。

f:id:sgtech:20190325102202g:plain


ノックバック(キャラクターが押される)

格闘ゲームやアクションゲームで見られる、攻撃やガードしたキャラクターが「少し後ろにずれる動き」です。単純そうですが、実はゲーム性とも絡む、奥が深い部分ではないか、と考えています。


アニメーション

アーティストが、キャラクターが攻撃を受けてのけぞったり、よろけたり、倒れなど、キャラクターアニメーションを作成します。ゲームアニメーター(モーションデザイナー)の担当部分です。
 キャラクターアニメーションの弱点は「決まった動きしかしない」です。これをどう崩すかが課題で、いろいろな動きを混ぜたり、物理シミュレーションを混ぜたり、各社いろいろな取り組みをしています。

 

例.「キャラクターに武器が跳ね返される」(オブジェクトシェイク、ノックバック、キャラクターアニメーションの合わせ技)

武器がキャラクターに当たって跳ね返されるアニメーション素材に、青いキャラクターの細かな振動や後ろに下がる動きの組み込みをプログラマー、どれくらい振動して後ろに下げるのかの調整をゲームデザイナーやアーティストがそれぞれ担当し、各パートの役割の合わせ技で表現します。
f:id:sgtech:20190325102457g:plain


変形(形で表現する)

「リアクション」は動きでモノが当たった感じを表現しましたが、「変形」は形で表現します。モノが当たったタイミングで、急激に形を変化させることで、当たった感じを表現します。
 ゲームでは現実と同じ壊れ方や変形を再現すると、非常に手間と時間がかかるので、記号的な変形が使われることがあります。

ゲーム中では車や壁、小物が一瞬で変形したり、シューティングゲームで敵機体が壊れていくのを確認できます。

例1.「ドラム缶が壊れる」

ゲーム特有の「記号的な表現」です。
現実では弾が当たるたびに少しづつ変形しますが、ここでは一定数弾が当たると変形する仕組みになっています。徐々に変形していくのがリアルですが、ゲームでは一気に形が変形したほうが気持ち良さが増します。
変形の効果だけでは当たった感じは弱いので、他の効果も合わせて表現します。

f:id:sgtech:20190325101405g:plain


例2.「キャラクターを踏み潰す」

青がプレイヤー、赤が敵のイメージです。
敵が踏みつけられたとき、敵がペチャンコになることで、プレイヤーに「敵をやっつけた」とわからせると同時に「気持ちよさ」を提供します。
f:id:sgtech:20190325101224g:plain


リアルタイム物理シミュレーション

ゲームエンジンでは物理シミュレーションが搭載されているので、より現実的な動きを表現できるようになりました。キャラクターアニメーションの弱点「決まった動きしかしない」は克服できますが、意図する動きにするには非常に時間と手間がかかります。

ヒットエフェクト

当たった瞬間にヒットマークを出したり、当たったモノを点滅させたり、エフェクトを表示する、ゲームでは必ず見かける手法です。

爆発や攻撃が当たったとき、カメラのストロボ発光のように画面をフラッシュさせる手法もありますが、光過敏性発作 (Wikipedia)という問題があるので、使用はあまり好ましくありません。アーケードゲームでは過剰な発光をしないよう計測しながら開発しています。


SE(効果音)

「音は物質を定義する」と何かで聞いたことがあります。例えば、白い画面に■ (ただの小さい四角形) が横切る動きに、蚊やハエの羽音のSEをつければ人は■を蚊やハエと認識できます。

ゲーム効果音にもいくつか種類があると考えています。

  • 質感を伝えるSE(想像系)
    現実ではありえない音ですが、質感が伝わる音。例えば20年前の2D格闘ゲームのガード音、キャラクターが敵に触れてやられたときのSE、30年前のシューティングゲームの爆発音など、「バシッ!」や「ドカーン!」ではない音
  • 見た目、イメージ通りのSE(リアル系)
    映画の効果音のようなリアルと感じられる音。
  • 特殊
    攻撃ヒット時に和音を鳴らしたり、音楽にヒットSEを合わせたりしたもの。

 

スナップ

アイテム欄に装備をドラッグ・アンド・ドロップした時、「カチッ」とハマる演出がこれにあたります。office製品やグラフィックソフトでも「スナップ」という機能があり、「ピタッ」とくっついた瞬間に気持ちよさと「合った!」という感覚があると思います。

例.「離れているキャラクターを引き寄せる」

あるゲームでは「キャラクターが離れていても引きつけて投げる」技があります。ゲームとして見事な手応えが感じられます。組み合った瞬間に「ウエイト」演出が含まれています。
f:id:sgtech:20190325104706g:plain

処理落ち、スロー

格闘ゲームの最後の一撃を決めて、ゆっくり吹っ飛ぶ演出がこれにあたります。ヘッドショットが成功するとスローになる演出のゲームもあります。「ここぞ!」という時に使う事で特別な、より強い手応えを表現できます。

例1.フィニッシュブロー

キャラクターの最後の一撃をくらったやられ演出です。もう少し「やっつけた!、やられた!」という余韻を提供したいところです。
f:id:sgtech:20190325102814g:plain

 

攻撃が当たった後、スローを入れました。最後の一撃の感覚を長く残すことで、やっつけた、やられたの感覚を強調することができます。
f:id:sgtech:20190325102642g:plain
 

例2.撃破

モノに当たった感じの表現とはずれますが、気持ちよさにつながる部分だと思います。
シューティングゲームのボス機体を破壊したイメージです。激闘の末にこの終わり方をしたら、プレイヤーはどんな気持ちになるでしょうか。
f:id:sgtech:20190325102554g:plain

最後の爆発でエフェクトの動きをスローにしました。「爆発した」ことを強調することで、倒した達成感を長持ちさせる効果があります。
f:id:sgtech:20190325110007g:plain

ハプティクス(皮膚感覚フィードバック)

身近なところではコントローラーの振動があります。ゲームエンジンでもコントローラーを振動させることができます。モノに当たった瞬間にコントローラーを振動させることで、当たった感じを提供します。
 その他、引っ張られる感覚が得られたり、圧力が感じられる装置の研究が進んでいます。(セガ・インタラクティブでも新しい感覚を伝えられる装置の調査、研究をしている部署があります)

 

 

以上がゲームでよく見られる「当たった感じがする『仕掛け』」だと考えています。

これらを組み合わせてゲームにしていきます。ただし、注意点があります。

  • 「意図したことがプレイヤーに伝わっているか?」「個々の原則は十分調整されているか」を考える
    例えば、迫力を出そうと爆発エフェクトを大きくしたところ、キャラクターの動きが見えない、変形が見えない、などのことがあります。それぞれの要素をバランスよく考える事が大事です。とは言うものの、私も実装してから「しまった」と思ったり、指摘されて気づくことが多々ありますので、これは永遠の課題です。
  • タイミング
    モノに当たって、エフェクトやリアクションを「いつ開始するか」が重要です。タイミングがずれていると原則を組み合わせてもよくなりません。

  

どうすれば? ~トレーニング法~

「ゲームアニメーションの難しい点」でも述べたとおり、感覚的な部分なので、作例を見て「なるほど!そういえば!」と思う方もいれば、「当たり前のことじゃん」「そうなんだ」「そうなのかなぁ」と思う方もいらっしゃるかもしれません。重要なのは、これらの原則を頭の片隅に置きつつ「ゲームを遊んで、実感して、自分なりの仮設を立てて、実践(制作)していく」ことです。

面白いと思うゲームを遊んで、なぜさわり心地がいいか考えるうちに表現力もあがる。一石二鳥ですね(笑)。

 

 最後に

面接で「ゲームが好きです!」という学生さんは多いのですが、もう一歩「ゲームのどんなところに注目して遊んでいるのか」「どこが、なぜよかったのか」「(気になる点があれば)自分だったらこうしたい!」を伝えられると、より良いアピールになるのでは?と考えてブログを書かせていただきました。

ゲームアニメーションを希望する方が増えることを願ってやみません。

 

セガ・インタラクティブは2019年2月より大崎オフィスに引っ越しました。コンビニや社員食堂、カフェ、バーコーナーなど充実した施設があるオフィスです。気持も新たに業務に励んでいます。

 

ご興味ありましたら、下記の弊社グループ採用サイトをご確認ください。

採用情報|株式会社セガ・インタラクティブ - 【SEGA Interactive Co., Ltd.】

 

 ©SEGA

 

Tricks of Realtime VFX with Houdini詳細解説 vol.1

こんにちは、セガゲームス龍が如くスタジオの伊地知です。

 去年もHoudiniのお話をこのSEGA TECH Blogで掲載しており、これで二回目となります。

さる2018年12月2日にCEDEC+KYUSHU2018が開催されたのですが

その時私の方で講演させていただいたTricks of Realtime VFX with Houdiniというセッションがありまして

(セッションのスライドはこちらからダウンロード出来ます。)

そのスライドの後半に

「実際にすぐにゲームに出して使えるエフェクトのテクニック」=「トリック」

を3つご紹介させて頂きました。

その3つのトリックを今回のSEGA TECH Blogで詳細に解説させて頂きます。

 

  1. 加工して戻す(RestSOP) ー processAndRest01.hip
  2. 独自シミュレーション(SolverSOP)
  3. 最短経路探索(FindShortestPath)

3ついっぺんにやると果てしなく長いので全部で3回に分け、

今回は1つ目である「加工して戻す」というトリックだけを扱うことにします。

シーンファイルもご利用頂けますしHoudiniの体験版(Apprentice版)でも開けますので

実際にご自身のPC(Windows)、Mac、Linuxで確認しながらご覧になるとご理解いただきやすいでしょう。

(HoudiniはいろんなOSに対応しています!) 

 

今回は初心者の方でも安心の詳細解説となっております。

またwrangleの行数も最低限に抑えてあります。 

操作方法や基礎知識に関しては1年前の前回のHoudiniの回でも

techblog.sega.jp

御紹介させおりますのでその辺が分からない方はそちらも合わせてご覧下さい。

 


1.加工して戻す(RestSOP)

では1つ目のトリックの内容から説明させて頂きます。

Houdiniの新規シーンを開いた想定でお話をさせて頂きます。

ダウンロードして頂いた方はシーンを上から順にデータを見ていって下さい。

まず画面一番左下のボタン

f:id:sgtech:20190224233230p:plain

を押してGlobal Animation Optionsを開きます。

FPS30End256にしてSaveAsDefaultボタンを押します。

f:id:sgtech:20190224233226p:plain

ネットワークエディタのオブジェクトレベルでTabキーを押しTabメニューでgeと押すと候補が出ますのでGeometryを選び作ります。

f:id:sgtech:20190224233347p:plain

f:id:sgtech:20190224233344p:plain

f:id:sgtech:20190224233340p:plain

できたgeo1にダブルクリックやiキーなどでジオメトリレベルに潜ります。

たいていの作業はこのジオメトリレベルで行う事になります。

 

TabメニューからSphereを選択してPrimitiveTypeをPolygon

Frequencyを50にします。

f:id:sgtech:20190224233155p:plain

 

次にTabメニューからUVTextureを出してつなげます。

TextureTypeをPolarFix Boundary Seamsにチェックします。

f:id:sgtech:20190224233149p:plain

この状態でビュー上でSpace+5を押すとUVの状態が確認出来ます。

f:id:sgtech:20190224233143p:plain

UVが01の範囲内からはみ出していると都合が悪いので

TabメニューからUV Transformを出してUV Textureにつなげ

ScaleXの値に 1/$XMAX と入力します。

これはUV値のUの最大値で全体を割るという意味で

こうする事によって01の範囲内に収めます。

f:id:sgtech:20190224233137p:plain

ビュー上でSpace+1を押してPerspectiveに戻します。

TabメニューからPointWrangleを出してUV Transformの出力につなげます。

f:id:sgtech:20190224233131p:plain

ポイントアトリビュートの@rest,@Cd,@Alphaを設定します。 

VEXpressionの欄に下記のコードを記述します。

v@rest = @P;
f@Alpha = 0.0;
@Cd = {1,1,1};

@はアトリビュートという意味でジオメトリのクラス(Point,Vertex,Primitive,Detail)自体に持たせる変数です。@の前のvやfは型を指定しておりvならベクター、fならフロートを定義します。ここではRunOverがPointになっているのでPointのクラスのアトリビュートを制御するという事になります。

@restはポジション(@P)を保持しておく為のアトリビュート。

頂点α(@Alpha)は0で初期化、頂点カラー(@Cd)は白で初期化という意味になります。

TabメニューからPointWrangleを出して先程のPointWrangleにつなげます。

f:id:sgtech:20190224233244p:plain

f@Alpha = 1.0;

VEXpressionの欄にはこの様に記述します。

再度Alphaを1にするのには訳がありますがそれはあとで解説します。

TabメニューからTwist( or Bend)を出して先程のPointWrangleにつなげます。

f:id:sgtech:20190224233240p:plain

Limit Deformation to Capture Regionのチェックを外し

Twistの値を360Capture OriginのZを-1Capture Length2にします。

こうする事で球がZ軸に沿って360度ねじれます。

TabメニューからMountaionを出して先程のBendにつなげます。

f:id:sgtech:20190224233233p:plain

MountainノードのHeight1.73Element Size2.18Scale0.06,0.16,0.06、

OffsetX値を-14に設定しY値は20に設定し1フレーム目Altキーを押しながらクリック256フレーム目18と設定しAltキーを押しながらクリック

これでパラメータの欄が緑色になったのが分かります。

これはアニメーションが設定されていますという事を現しています。

ちなみにShiftを押しながらクリックでアニメーションエディタが開きます。

Max Octaves9Lacunarity2.19Roughness0.407とします。

この状態で再生ボタン(画面左下にあります)を押すか、カーソルキーの↑を

押してみて下さい。

f:id:sgtech:20190224233321g:plain

球のトゲトゲが蠢きながら下に流れていくアニメーションが確認出来ます。

確認出来たら停止ボタン(画面左下にあります)を押すか、Ctrl+↑を押して止めて下さい。

TabメニューからTransformを出して先程のMountainにつなげます。

f:id:sgtech:20190224233317p:plain

RotateX値を31Y値を5Z値を5とし少し回転させます

TabメニューからClipを出して先程のTransformにつなげます。

f:id:sgtech:20190224234332p:plain

先程と同じ要領でDistance1フレーム目-1.25256フレーム目1.36とキーを打ちます。

この状態で再生させると下半分が削れた状態が段々と上に上がっていって

トゲトゲの球が消えていくのが分かります。

TabメニューからPointWrangleを出して先程のClipにつなげます。

f:id:sgtech:20190224234328p:plain

@P = @rest;

VEXPressionの欄にこの1行を入力すると歪められていたポイントのポジションが元に戻ります。

この挙動を不思議に思う方もいらっしゃるかもしれませんがこれは

単純に@restに保持していた座標で元の位置に戻っただけなのです。

しかし消されたポイントは戻りません。

歪んだ状態で水平に切るという事は座標を戻すと水平では無く歪んだ状態で削れていくのです。

↑キーで再生させるとこの様に動作しているのが確認出来るでしょう。

f:id:sgtech:20190224234240g:plain

映像の表現ならここまでで良いですがゲームの場合

ゲーム中で表現する為に工夫が必要です。

 CEDEC+KYUSHU2018では私はテクスチャパターンアニメーションでやりましょうと言ってしまいましたが実はもっと良いαカットオフを用いた手法があります。

シェーダ側でαしきい値を用いて透明にするのですが

そのαをどう出すのかがこのトリックの鍵になります。

2つ目のトリックで紹介するSolverというノードをもうここで使います。

毎フレーム値を加工して蓄積させていく事が出来るノードで評価した結果を

キャッシュしておくことが出来ます。

TabメニューからSolverを出して上から4つ目のpointwrangle1から

左から1番目のところにつなげて左から二番目の入力にpointwrangle3をつなげます。

 

f:id:sgtech:20190224234238p:plain

ダブルクリックしてsolver1の中に潜ります。

 TabメニューからAttributeTransferを出してPrev_Frameを第一入力に

Input_2を第二入力につなげPointsにチェックを付けAlphaを指定します。

f:id:sgtech:20190224234234p:plain

AttributeTransferは第一入力に対し第二入力のアトリビュートを近いものから転写していくノードです。MayaであればTransferAttribute、Softimageをお使いだった方ならGATORを御存知だと思いますがその万能版だと思って頂ければ理解が早いかと思います。(Softimageユーザーの方ならMayaのTransferAttributeなんかと比べられるのは屈辱だと思いますがHoudiniのAttributeTransferからすればどちらも五十歩百歩です。)

SolverSOPの中でこの様なつなぎ方をすると前のフレームの結果に対し現在のフレームの第二入力のアトリビュートを転写するという意味を持ちます。

TabメニューからPointWrangleを出して第一入力にPrev_Frame、第二入力にAttributeTransferをつなげます

f:id:sgtech:20190224233439p:plain

VEXpressionの欄には 

@Alpha += @opinput1_Alpha/256;

 と記述します。

このコードと接続の意味は前のフレームの結果のアルファに対し

第二入力のアルファを256で割った数値を加算するという意味になります。

つまりこの2つのノードの意味は毎フレーム現在のアルファ値の1/256を

加算し続けるということになります。

 

uキーで1つ上の階層に戻って256フレーム目まで進めます。

すると球がこの様に見えているはずです。

f:id:sgtech:20190224233435p:plain

毎フレームアルファ値を累積して256フレーム分貯めた結果がこれです。

これをUV座標に基づいてテクスチャに書き出してやります。

GameDevelopmentToolsetのSimpleBakerを使うやり方が最も簡単でしょう。

GameDevelopmentToolsetのインストールの最も簡単な方法は左上の

GameDevelopmentToolsetタブのUpdateToolsetボタンを押すことです。

f:id:sgtech:20190224233432p:plain

ゲーム会社や映像スタジオ、CGスクールなどでプロキシ環境下であれば

この機能がうまくいかない場合があります。

その際は公式のgithubからダウンロードして手動でインストールして下さい。

GameDevelopmentToolsetがインストールされた状態になったら

TabメニューからGameDev Simple Bakerを出して下さい。

f:id:sgtech:20190224233430p:plain

ここで普通に考えればアルファにチェックを入れれば出力出来るはずなんですが

何故か真っ白になってしまうので一旦@Cdに@Alphaを移して

basecolorとして出力する事になります。

 simple bakerの1つ前にPointWrangleを足して

@Cd = @Alpha;

 記述して頂点カラーにアルファを移してからsimple baker でテクスチャを焼きます。

すると

f:id:sgtech:20190224233426p:plain

この様なテクスチャが焼き上がりますのでPhotoshop上でレベル補正かけたり

トーンカーブで補正かけたりして1~254くらいの値の範囲にしておくと

シェーダに食わせた時の見た目が良い様です。

 

モデルの方も同時に出力しておきます。

 UVを設定したuvtransform1から出すと良いのですがそのままだと

25,000頂点もありかなりメモリを食ってしまいます。

uvtransform1の下にPolyReduceを作ってつなげます。

 Percent To Keep1Vertex Attribute Seams0.4にして頂点数を252にまで

落とします。ゲーム内で綺麗に見える最低限の頂点数であれば Percent To Keepを

いろいろ試してみても良いでしょう。

f:id:sgtech:20190224233533p:plain

TabメニューからROP FBX Outputを出してつなげます。

 Output Fileを指定してSave to Diskボタンを押します。

これでモデルファイルも出力出来ました。

 ではUnity上で確認してみましょう。

普通のスタンダードシェーダでは両面に対応していないので

Create -> Shader -> Standard Surface Shader

で作ったものにちょい足ししたシェーダで表示してみましょう。

Shader "Custom/doubleSidedCutOff" {
    Properties {
        _Color ("Color", Color) = (1,1,1,1)
        _Cutoff ( "Cutoff", Range(0, 1) ) = 0.5
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _Glossiness ("Smoothness", Range(0,1)) = 0.5
        _Metallic ("Metallic", Range(0,1)) = 0.0
        _BumpMap ( "Normal Map" , 2D ) = "bump" {}
        _BumpScale ( "Normal Scale" , Range(0,1) ) = 1.0
    }
    SubShader{
        Tags { 
            "Queue" =    "AlphaTest" 
            "RenderType"="TransparentCutout"
        }
        LOD 200
        Cull Off
        CGPROGRAM
        // Physically based Standard lighting model, and enable shadows on all light types
        #pragma surface surf Standard fullforwardshadows alphatest:_Cutoff

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

        sampler2D _MainTex;
        sampler2D _BumpMap;

        struct Input {
            float2 uv_MainTex;
        };

        half _Glossiness;
        half _Metallic;
        half _BumpScale;
        fixed4 _Color;

        // 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)
        {
            // Albedo comes from a texture tinted by color
            fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
            o.Albedo = c.rgb;
            // Metallic and smoothness come from slider variables
            o.Metallic = _Metallic;
            o.Smoothness = _Glossiness;
            o.Alpha = c.a;

            fixed4 n = tex2D( _BumpMap, IN.uv_MainTex);
            o.Normal = UnpackScaleNormal(n, _BumpScale);
        }
        ENDCG
    }
    FallBack "Transparent/Cutout/VertexLit"
    
}

このシェーダをアサインしたマテリアルを用意して適当なテクスチャを貼ってあげます。

その際テクスチャのアルファに先程生成したテクスチャを入れ込んでおきます。

するとこの様に表示されます。

youtu.be

 

@restに座標を保持して戻す時に削れたpointが戻らないのは当然としても

生成されたpointがちゃんと良い感じの場所に戻ってくれるのは凄いと思います。

中で一体どういう処理が走ってるんでしょうか?気になります。 

累積アルファのカットオフはテクスチャパターンアニメーションでやるより

遥かに質も向上しメモリも削減出来る賢いやり方なので

覚えておいて損は無いでしょう。

 

さて今回はここまでです、いかがでしたでしょうか?
ゲームに出力する部分は様々な手法を考慮し工夫する必要があります。

現状ビルボードが主流のゲームエフェクトですがこんなトリックを用いる事で思いがけない表現をプレイヤーの方々にお見せし感動体験を演出する事も可能になるのです。

ゲームエンジン側の知識もフル動員すればもっと凄いものが表現出来るでしょう。

勉強する事は山程ありますし考えなければいけない事もいっぱいあります。

ですがやりたい表現が達成出来た時の喜びもまたひとしおです。

次回のHoudiniの記事もお楽しみに。 

 

この記事に興味を持って頂けた方は弊社で私達と一緒に働いてみませんか?

弊社ではHoudiniに興味を持って取り組めるような人を募集しています!
我こそはという方、興味のある方は以下のリンクを是非クリックしてみてください。

 
sega-games.co.jp

 

©SEGA

 

Are you readyyy to Deep Learning!?

 セガゲームス第4事業部第4開発1部TA(テクニカルアーティスト)セクション*1 宮下です。2019年が始まって早1カ月がたとうとしてますが、あなたにとって2018年はどんな年でしたか?(少々時期外れな質問なのですが…)

Readyyy!

 私は2019年2月1日にリリースされたスマホタイトル「Readyyy!」に携わっており、とても忙しい1年でした。このタイトルは、プレイヤーが新人プロデューサー兼寮長として、男子高校生アイドル18人を育成するスマホゲームで、ゲームエンジンUnity*2を使って開発しています。

f:id:sgtech:20190120235301p:plain

Readyyy! キービジュアル

 TAとして、3Dによるチビメンや「Live2D*3」の立ち絵をはじめとする「Readyyy!」の技術的なグラフィック表現の根幹を担っています。特に立ち絵については、ライティングという結構面白いことしているんですよ。

 昼間、夕方、逆光などの環境によるライティングを実現するための仕組みの設計と、それに関連するシェーダーやコンポーネント*4を作りました。そのデータを「Photoshop」や「Live2D」から出力するためのツール整備もしています。

f:id:sgtech:20190120235043j:plain

昼間順光の表現

f:id:sgtech:20190120235101j:plain

夕焼け逆光の表現

 ご興味あれば、ぜひ遊んでみてください!

ready.sega.jp

CEDEC+KYUSHU2018

 実はこのセガテックブログのおかげで、昨年12月に「CEDEC+KYUSHU2018」での講演という、貴重な体験をさせていただきました。おかげというのは、「CEDEC+KYUSHU2018」の関係者の方が、2017年に私の書いた記事、

techblog.sega.jp

をご覧になって、講演を依頼してくださったのです。

 今回は、そのとき講演した5つの自動化効率化のお話*5の中から、ディープラーニングをテーマにしたものにプラスアルファしてお届けします。

 「CEDEC+KYUSHU2018」での講演をお聞きになった方も復習を兼ねてご覧いただければと思います。説明も、より丁寧になっていますので!

ディープラーニングで自動的にサムネイルを作れないか!?

 前置きが大変長くなりました。それでは始めましょう!

 なお、ディープラーニングを勉強するのにゼロから作るDeep Learning ―Pythonで学ぶディープラーニングの理論と実装」という本を使っています!難しいですがとてもいい本です。

アニメ顔検出

 2016年ごろ、とあるデザイナーさんからこのような相談を受けました。

「サムネイルを自動的に作れませんか?」

f:id:sgtech:20190120234116j:plain
f:id:sgtech:20190120234114j:plain
f:id:sgtech:20190120234112j:plain
f:id:sgtech:20190120234110j:plain
f:id:sgtech:20190120234107j:plain

 サムネイルとは縮小した画像のことですが、このように顔など特徴的な箇所をクローズアップしてトリミングするケースもあります。ちなみに彼らは、「Readyyy!」の「SP!CA」というユニットのアイドルたちです。

 そのデザイナーさんが所属しているプロジェクトのプラットフォームはスマホでした。スマホタイトルは運営を長く続けていくため、新規要素をどんどん追加していきます。その要素を一覧で表示させたりするために、サムネイルが必要となるのです。

 また、ご存知とは思いますがゲーム開発ではアニメ顔が使われることが多く、そのプロジェクトでもアニメ顔が採用されていました。

 これを自動的に作成…何か良い方法はないものか…。検索すると…出てきました!「OpenCV」を使ったものです。

ultraist.hatenablog.com

 多くの人がこれを使ってアニメ顔検出しているようですね。勉強も兼ねて「Photoshop」のプラグインという形で実装してみました。一から実装すると大変なので、サンプルのプラグインに間借りする感じで作りました。
 余談ですが、「Photoshop」プラグインのAPIって独特というか、なんとも難しいですよね…。下のgif動画は、プラグインを動作させたときのものです。

f:id:sgtech:20190120234252g:plain

Photoshopプラグイン

 どうですか!?素晴らしい性能ですね!ただ、おしいのが誤検出(下の半透明で赤く塗り潰した部分)です。

f:id:sgtech:20190120234339p:plain

 このOpenCV(lbpcascade_animeface)を使った手法は検出精度の調整ができるのですが、精度を上げすぎると、顔ではない部分も検出してしまい、精度を下げると、顔を検出しづらくなってしまうという現象が発生します。そのちょうどよいバランスを見つければいいのかもしれませんが、その設定で絶対大丈夫と言い切る自信がありません。残念ながら、これでは自動生成するツールとして、信頼性が足りませんよね…。

顔なのか?顔ではないのか?

 しばらくして、1つのアイデアが閃きました。OpenCV(lbpcascade_animeface)が検出した画像の、「顔」「顔ではない」をディープラーニングで学習させて、判断させるようにすればいいのではないか…と。ここではディープラーニングの代表的な手法、畳み込みニューラルネットワーク(Convolutional Neural Network、以下CNNと呼びます)を用います。本来なら切り出した顔などの特徴量から誰なのかを判定できる素晴らしい手法なのですが、今回は贅沢(?)に、「顔」「顔ではない」の2種類の判定のためだけに使います。

 後で知ったのですが、この物体検出後にCNNで分類する手法は「R-CNN」という立派な名前がついていました。

Keras

 ディープラーニングのフレームワークには、簡単で分かりやすいという評判のKeras*6を使いました。KerasはPythonによるフレームワークで、TensorFlowやTheanoなどのディープラーニングライブラリをバックエンドとして選択できます。今回バックエンドには、CPU版TensorFlowを使いました。

 KerasやCNNについては、こちらの記事を参考にしています。

qiita.com

教師データ

 ディープラーニングを始めるにあたって、まず教師データというものを大量に用意します。ここでは「顔」と「顔ではない」画像ファイル群ですね。実は、この教師データを用意するところに1つのハードルがあります。個人で用意する場合「大量に」という点が大変だと思うのですが、セガでは複数のタイトルを開発していますので、他のプロジェクトから画像を提供してもらいました。これに先ほどのOpenCV(lbpcascade_animeface)を使ったアニメ顔検出をさせて、「顔」と「顔ではない」画像群を、比較的簡単に用意できました。

f:id:sgtech:20190120234423j:plain

顔 700枚

f:id:sgtech:20190120234420j:plain

顔ではない 1000枚

 これで準備が整いました。100回学習させてみると、7時間ほどかかりました。

 学習させた成果のことをモデルと呼びます。モデルは基本的に学習回数が多いほど、性能が良くなります。ただし、特定の教師データにだけ過度に対応した状態、過学習(overfitting)になってしまうことがあります。未知のデータに対応できない状態ですね。

 下の図は今回学習させたモデルの性能を表しています。

f:id:sgtech:20190120234336p:plain

 「acc」は正答率、「loss」は性能の悪さを示す指標で、教師データに対してどれだけ一致していないかを表しています。横軸は学習回数で、多いほど性能が良くなる傾向を示しているのがお分りいただけると思います。

 また、実は教師データの全てを学習に使っているわけではなく、教師データの一部は未知のデータに対応できるかどうかのテストに使っており、そのテスト結果が、val_acc」val_loss」です。

 緑のグラフval_loss」は、学習を繰り返すたびに上昇していく傾向にありますが、これは過学習状態であることを示しています。なので100回ではなく、30回ぐらいで止めておいたほうが良いモデルと言えそうですが、今回はこのまま100回学習したモデルで話を進めます。

 では、このモデルを切り出した画像に適用してみましょう。

f:id:sgtech:20190120234332j:plain

学習の成果は!?

 「Readyyy!」のキービジュアルでの判定の成果はこのようになりました。7番だけ99%顔と判断して失敗してしまいましたが、この手法は有効そうです。

デザイナーが使えるようPhotoshopで動作させてみる

 では、この顔検出+顔判定のシステムを、「Photoshop」から使えるようにしてみましょう。システムはPythonで動作しているので、「Falcon*7」というフレームワークを使って、WebAPIとしてアクセスするようにしてみます。手順としては、

  1. Photoshop上でドキュメントを一時的にファイル(JPEG等)保存する。
  2. Photoshop上でそのファイルを読み込んで、WebAPIを使ってアップロードし、応答を待つ。
  3. 画像を受け取ったWebサーバー側で、アニメ顔を検出する。
  4. 引き続きWebサーバー側で、「顔」か「顔ではない」かを判定し、「顔」の矩形の座標を返す。
  5. Photoshop上でその結果を受け取り、選択範囲として反映する。

となります。

 今回は「Photoshop」上での動作には、プラグインではなくスクリプト(JavaScript)を使います。青い文字が「Photoshop」スクリプトでの処理赤い文字がWebサーバーでの処理となります。

 「Photoshop」スクリプトでバイナリデータをアップロードする方法は、ここに良いコードがありますので、参考にしてみてください。

forums.adobe.com

 「Falcon」を使ったWebAPIも簡単に実装できます。今回はこちらを参考しました。

qiita.com

f:id:sgtech:20190120234223g:plain

WebAPI+Photoshopスクリプトによる実装サンプル

 対象の画像内に複数人いる場合は最初に見つかった人の顔の矩形を返します。上の動画では、左から2番めの茶色の髪の比呂君が選択されてますね。

 これで、「Photoshop」スクリプトを使って、画像から自動的にサムネイルを作りだせるようになりました。

  ここまでが、「CEDEC+KYUSHU2018」でディープラーニングについて講演した内容です。

ツールと連携させる(プラスアルファ)

 ここから先が今回追加する内容で、「Readyyy!」で試みた★1と★2フォトの自動生成をご紹介します。人間並みの高い精度でアニメ顔を矩形選択できるのであれば、いろいろ応用できそうですよね。

 ちなみにフォトというのは、「Readyyy!」内で手に入るアイドルの写真のことです。★5フォトは、このような豪華なフォトです。

f:id:sgtech:20190120235131j:plain

★5フォト

 ★1と★2のフォトは、アイドル画像と汎用的な背景画像を組み合わせたシンプルなフォトです。

f:id:sgtech:20190120235139j:plain

★1フォト

 なお、連携させるツールのフローは以下のようなもので、全て「Photoshop」上での操作です。

  1. アイドル画像を読み込み、あとでデザイナーが位置調整しやすいように、スマートオブジェクト化しておく。
  2. アイドル画像の顔の部分を選択範囲として囲む。
  3. 背景画像を読み込む。
  4. その背景画像にあらかじめ指定してある基準位置と、先ほどのアイドル画像の選択範囲の大きさが一致するように調整して、アイドル画像をコピー&ペーストする。
  5. 完成した画像を書き出す。

 2番(赤い文字の部分)にアニメ顔検出判定システムを組み込んで、自動化を試みます。では実行してみますね。動画の左側が「Photoshop」、右側が出力フォルダの様子です。また20倍速で再生しており、本来はもっとゆっくりとした動作となります。 

f:id:sgtech:20190121133210g:plain

ツールへの組み込み

 よし!どんどん生成されている!結構いい感じです!

f:id:sgtech:20190120235152j:plain

顔の大きさが違う…

 ただ極端な例なのですが、このあたりを比較してみるとアイドルの顔の大きさがそろっていません。このあたりの微調整は、デザイナーさんにがんばってもらいました…。さらなる自動化を目指すには、工夫が必要そうです。

 今回は検出にOpenCV(lbpcascade_animeface)を使いましたが、検出自体をDeepLearningで行う手法(Faster R-CNNもあるので、次はこちらも試したいと思っています。

まとめ

  • アニメ顔検出は、OpenCV(lbpcascade_animeface)が使える
  • 誤検出には、ディープラーニング(CNN)で対応
  • 検出されたアニメ顔のトリミングに課題あり

最後に…

 ここまで読んでくださり、ありがとうございました。課題も見つかり今回は道半ばという結果になってしましたが、いかがだったでしょうか?今後も継続的に改良を積み重ねて、またここでお伝えできればなぁと思っています。

 TAセクションではこのようにデザイナーの制作に役立つ環境を提供できるよう力を注いでおります。Unityの登場でTAの活躍できるフィールドは格段に広がり、シェーダーやポストエフェクトなど面白い表現を生み出せないかと日々格闘中です。

 そんな中でお仕事したい方は、ぜひ下記の弊社グループ採用サイトをご確認ください。いっしょに働いてみませんか?

sega-games.co.jp

 そして、「Readyyy!」、よろしくお願いします!

 

*1:1月に組織改編がありまして部署名が変わりました。

*2:https://unity3d.com/jp

*3:ここではLive2D Cubismのこと。2Dイラストに擬似3D的な滑らかなアニメーションを追加することのできるソフト。Spineという海外の競合ソフトがある。また、最近はVTuberにも良く使われている。https://www.live2d.com/ja/products/cubism3

*4:Unityにおいてオブジェクトの振る舞いを記述するためのスクリプトのこと。

*5:https://cedil.cesa.or.jp/cedil_sessions/view/1972

*6:https://keras.io/ja/

*7:https://falconframework.org/

龍が如くにおけるキャラクター制作ワークフロー

初めまして。
セガゲームス 第1CSスタジオの有賀千陽です。
キャラクターデザインの業務に10年以上携わったのち、現在は新設されたデザインサポートチームでデザイナーの作業を支援するツールを制作しています。

 

「龍が如くスタジオ」で制作されているタイトルにはたくさんの実在の人物が登場します。
今回のSEGA TECH Blogでは「龍が如くスタジオ」キャラクター班流のリアルなキャラクターを作成するフローを皆さまにご紹介させていただきます。

【目次】

 

まずは「龍が如く」シリーズのキャラクターの特徴から説明しましょう。

f:id:sgtech:20190313135651j:plain

  • プラットフォームはPS4などハイエンド機である
  • 開発期間が短い※おおよそ一年
  • 芸能人とタイアップし、ご本人がゲーム中でキャラクターを演じている
  • 沢山のユニークなキャラクターが登場する

などが挙げられます。

 

そのリアルなキャラクターたちを短い期間にどれくらいのクオリティで、どれくらいの顔の数を作らないといけなかったのか?

クオリティはもちろん写真のようなクオリティです。

f:id:sgtech:20190313135644j:plain

タイアップキャストになると誰が見てもそっくりに作らなくてはなりません。

 

次に数の話ですが…

f:id:sgtech:20181224005946j:plain

1プロジェクトで使用したキャラ(男性のみ)

『龍が如く6 命の詩。』の男性NPC*1の顔だけで上記の画像ぐらいです。

 

これに加えて女性NPC・重要キャラクター・キャバ嬢のタイアップキャラなどを含めると、

  • 男性の顔:約200種 
  • 女性の顔:約70種 

あいかわらずたくさん作ってますね…。まとめると、

短時間でリアルなキャラを沢山作らないといけない!

リアルなキャラを大量に作成するに当たり活躍してくれたのが、これから説明するフォトグラメトリーを活用した制作フローでした。

f:id:sgtech:20181224005310p:plain

 

まずは、フォトグラメトリーの説明と、フォトグラメトリーによる3Dの自動生成、Maya等のDCCツールに持っていくまでのフローの説明をします。


フォトグラメトリーとは?
------------------------------------------------------------------------------------
3次元の物体を複数の観測点から撮影して得た2次元画像から、
視差情報を解析して寸法・形状を求める写真測量のこと。

小難しくてよくわかりませんね…

要は撮った写真から自動で3Dデータを生成してくれる技法のことです。
この文言だけで、かなり簡単に作れるように感じられるのではないでしょうか?

 

フォトグラメトリーを本格的に導入したのは『龍が如く6 命の詩。』の開発中でした。

ではそれ以前はどうしていたのか?


「龍が如くスタジオ」で行っていた3Dスキャンの歴史を簡単に説明します。

「龍が如く」シリーズにおける3Dスキャンの歴史

  1. リアリティの追及
  2. 制作スピードの向上

この2つの要求を満たすために、「龍が如く」シリーズでは2007年PS3対応が始まった『龍が如く 見参!』の制作から3Dスキャンによるワークフローが導入されました。

PS2世代までは写真を見てモデリングを行っていました。いわゆる目コピーです。つまり制作クオリティはデザイナーのスキル頼りだったのです。

PS3世代になってゲーム機のスペック向上に伴い、リアリティのあるゲームモデルの必要性が高まり、さらに『龍が如く 見参!』から実在の俳優を登場させることにもなり、リアリティとクオリティの追及がさらに重要視されるようなりました。

光学式スキャナー

2007年の『龍が如く 見参!』以降『龍が如く3』『龍が如く4 伝説を継ぐもの』『龍が如く OF THE END』まで社外のスタジオで役者さんを撮影し、メッシュデータもそのスタジオで生成されたものを使用していました。
3Dスキャンを行うようになりキャラクターのモデリング時間は40%削減でき、クオリティも上がりました。

f:id:sgtech:20181224005306p:plain

外部スタジオを利用していたころのスキャンメッシュ

そのスタジオでは当時としては最新式だった光学式(ヘリウムネオンレーザー)の3Dスキャナーを使用していましたが、スキャンメッシュの精度はごらんの通り、なんとなく誰だか認識できる程度で細かいディテールは撮れていません。

 投影式の3Dスキャナー

以下の画像は『龍が如く OF THE END』の途中から導入が始まった社内撮影スタジオでスキャンされた3Dモデルです。

f:id:sgtech:20181224005302p:plain

投影式3Dスキャナを利用してキャプチャされた3Dモデル

 

この頃から社内に撮影専用スタジオを設け、テクスチャはカメラ1台(手持ち)での撮影からスタートし、後にカメラ5台(+三脚・ストロボ)でシャッターを同期させて撮影する方法に移行。より精度の高いテクスチャを撮影できるようになりました。

3DモデルのキャプチャにはWhite-Light (走査型白色)方式*2の3Dスキャナーを導入しました。プロジェクターを用い撮影対象に画像を投影して専用ソフトウェアで解析して3Dメッシュを生成するという方法です。

社内に専用スタジオを設け新しい3Dキャプチャのシステムを導入したことにより、メッシュの精度が以前より向上し時間面でも費用面でも社外スタジオの利用時に比べて大幅なコストカットを達成しました。

 ただ、暗室で5秒程度息を止めて動かないでいる必要があるため、まばたきや体が動いてしまうだけでメッシュの精度が低下するなど正確な3Dモデルのキャプチャは非常に難しく、撮影されている人の負担も大きかったのです。

さらに、社外スタジオのスキャンデータも社内スタジオのスキャンデータも、どちらもテクスチャの生成ができなかった為、3Dメッシュを生成した後撮影した画像をメッシュにベイクしてテクスチャを自分たちで作成する必要がありました。

f:id:sgtech:20181224005259p:plain


その頃のテクスチャ作成方法は、DCCツール内部に画角が同一となるカメラを作成し、位置を合わせてカメラプロジェクションUVを作成しテクスチャベイクしていたのですが、この作業だけで数時間かかっていました。

f:id:sgtech:20181224005255p:plain

また、社外スタジオのスキャンデータも社内スタジオのスキャンデータもそのままではノーマルマップベイクのソースにできるほど精細にキャプチャできるわけではなく、Zbrushでのスカルプトによるディテールの追加作業は必須でした。

 

PhotoScanの導入

そんななか、フォトグラメトリーの技術を使い3Dメッシュを生成するPhotoScanというソフトウェアがプロジェクトに導入されました。

 3Dデータ生成用の写真撮影の様子

f:id:sgtech:20181224005401p:plain

上の写真は2015年から2018年現在まで使用している社内フォトスタジオです。

事業所移転前の羽田にあった旧スタジオの様子なので手作り感あふれる仕上がりですが、ここで沢山の芸能人の方々をスキャンしてきた見た目にそぐわずできるヤツなんです・・・が、なんと!現在大崎の新オフィス内に「龍が如くスタジオ」キャラクター班こだわりの新スタジオを構築中なのです!
今年度中に完成予定ですのでお楽しみに。

話は戻って、カメラのスペックは以下のようになっています。

  • 台数 : 30台
  • 機種 : CanonX7i
  • レンズ : 標準ズームレンズ
  • 焦点距離 : 55mm

基本設計や機材の調達は社外の専門家に委託し、その後の運用、撮影のオペレーション、メンテナンスなどはキャラクター班が行っています。

 画像の現像

X-Rite社のColorCheckerPassportと一緒に撮影した画像からカメラプロファイルを作成し全ての現像に使用します。

f:id:sgtech:20181224005357p:plain

 

秘訣は 「龍が如くスタジオ」独自の「ドラゴンエンジン」

「龍が如く」シリーズは常に部内開発のゲームエンジン上で制作されてきました。そうすることでレスポンスが良く柔軟なゲーム開発が可能になり、デザイナーの時には無茶な要求にも短時間で実装することができるのです。
『龍が如く6 命の詩。』の開発時にアップデートされた内製ゲームエンジン「ドラゴンエンジン」は、それまでのゲームエンジンよりシームレスでリアルな表現が可能になりました。

部内でゲームエンジンを開発できるような高い技術力を持ったプログラマがたくさんいるのも、「龍が如くスタジオ」がクオリティの高いゲームを素早く作れる要素の一つとなっています。

キャラクター制作ワークフロー

前置きがだいぶ長くなってしまいましたが、本題のキャラクターワークフローの解説に移ります。
今回は「龍が如く」シリーズ関連のプロモーションでおなじみ島野さんの顔制作に沿ってお伝えします。

3Dメッシュ生成

3Dメッシュの生成にはAGISoft社のPhotoScanというソフトウェアを使用しています。

f:id:sgtech:20181224005951j:plain

PhotoScanの操作画面

このソフトウェアの導入により3Dメッシュの生成と同時にテクスチャも生成できるようになったことから、テクスチャをMaya上でベイクしていた数時間の作業が削減されテクスチャ作成時間が大幅に短縮できました。


この一連のフローにより、デザイナーのスキルに依存していたクオリティの向上と制作時間が短縮し安定して高水準なクオリティがアウトプットできるようになったのです。

メッシュ生成 フロー

では実際に3Dメッシュを生成する過程を紹介しましょう。

  1. 始めに現像した撮影画像をツールに読み込みカメラ位置を計測
  2. ポイントクラウド(点群データ)を生成
  3. 3Dメッシュを生成
  4. 最後にテクスチャベイクで終了

PhotoScnanは各画像のカメラの位置や画角を画像解析により取得し、ソフトウェア上で撮影環境を再現しますが、ソースにしている写真の点数が少ないと各画像の位置関係が予測できずカメラの位置情報が取得できなくなります。

現在弊社スタジオは30台のカメラで構成されており、横の耳から後ろの首回りまで360度カバーできるようになりました。

f:id:sgtech:20181224005825j:plain

高密度クラウド

上記の画像はポイントクラウド(点群)です。

この状態では点群と認識できないくらい高密度なので寄ってみます。

f:id:sgtech:20181224005822j:plain
更に寄ってみます

f:id:sgtech:20181224005818j:plain

 小さい点が見えるのがわかりますか…?

 

このポイントクラウドを元にメッシュとテクスチャを生成します。

f:id:sgtech:20181224005948j:plain

PhotoScanで生成したポリゴンメッシュ



上の画像は生成したメッシュデータです。1000万ポリゴンで生成しています。

 

f:id:sgtech:20181224005815j:plain

テクスチャ表示とUV

上の画像はテクスチャです。
PhotoScanが生成したポリゴンメッシュとUVに写真がベイクされます。

これらを任意の形式でエクスポートしてR3DS社のWrapXというツールでPhotoScanからエクスポートした3Dメッシュにベースメッシュをシュリンクラップします。
この工程の詳細は後ほど解説しますね。

 

顔モデル作成フロー

PhotoScanやWrapXで得たソースを元に実際にどのようにゲームのデータに落としこむのでしょうか。

まずはスキャンメッシュとゲームメッシュを合わせるという作業になります。

f:id:sgtech:20181224005353p:plain

これはPhotoScanで生成したメッシュです。

f:id:sgtech:20181224005350p:plain

これがWrapXで生成したメッシュです。

f:id:sgtech:20181224005346p:plain

実際に合わせてみるとこうなります。

わかりにくいですが目の周りのエッジフローが少し歪んでいますね。

ゲームメッシュのエッジのラインがきちんとスキャンメッシュの特徴に沿うように修正していきます。

目の周りはまつ毛や二重や粘膜の部分など細かい造形が多くPhotoScanでは再現しきれません。口の周りも唇の内側など写真には写らない部分はメッシュが生成されないので、その二か所はどうしても手作業で修正する必要があります。

f:id:sgtech:20181224005536p:plain

f:id:sgtech:20181224005533p:plain

f:id:sgtech:20181224005529p:plain

これらの作業は非常に重要で、メッシュの法線情報を正しく作ることによりクオリティを上げるという目的もありますが、「龍が如く」シリーズではフェイシャルモーションが複雑に動くためエッジフローが実際の筋肉の流れに沿っていないと後々イベントムービーシーンで思わぬ破たんを生みだす原因となってしまいます。

この作業に関してはWrapXの導入でかなり効率化されました。

テクスチャ作成

次はテクスチャ作成なのですが、その前に簡単に描画周りを説明しておきます。

「龍が如く」ではいわゆる主流ではない、f0値を扱ったPBRを採用しています。

この理由は、「龍が如く」というプロジェクトがゴリゴリのフォトリアル指向ではないことと、ゲームに妖怪や怪物*3など現実にいないキャラクターが登場する可能性を加味し、ある程度振り切った表現ができる今の形に収まりました。

 

テクスチャについて

次は「ドラゴンエンジン」で使われているテクスチャについてです。

f:id:sgtech:20181224005954j:plain

顔一つの表情に沢山の種類のテクスチャが使われています

上記の画像にあるような8枚*4のテクスチャをキャラクター班で作成して使用しています。
DiffuseMap、NormalMap、ShinnessMap、AmbientOccrusionMapは、おそらく大体のプロジェクトでお馴染みだと思いますので説明は割愛します。

アンビエントオクリュージョンマップ

アンビエントオクリュージョンマップをわざわざ1枚のマップで持っているのは、キャラ単体では画面を占める割合は低くスクリーンスペースのアンビエントオクリュージョンが充分に乗らないので個別でベイクしキャラごとに用意しているからです。

トランスミッタンスマップ

肌透過用のトランスミッタンスマップは、肌の薄い部分を表現するために使っています。逆光を当てられた時の耳や、指などに効果的です。

F(0°)マップ

黒い画像ですが、F(0°)マップと呼んでいます。
F(0°)とは屈折率の比から求まる、0度のフレネル反射率という意味らしいです。
屈折率0度、法線方向から見たときの、鏡面反射率、スペキュラーの総量です。
mayaにあったmental_rayのマテリアルでは、IOR(屈折率)と書かれていることからリフレクションの強さにも影響します。値が高いほど正面から見ても環境が映りこみます。

キャラクター班としては素材ごとに担当プログラマーに用意してもらった値で作業するという認識です。

テクスチャ作成フロー

次にテクスチャの作成フローです。

龍のテクスチャワークフローは、タンジェントノーマル、AO、シャイニネスの順番に一連の流れで制作しています。
ディフューズは状況によって前後いたしますが、スキャンメッシュと生成された画像をソースに、ゲームメッシュでベイクすればほぼ終了です。

タンジェントノーマルマップ

まずは16bit高解像度テクスチャを利用してディスプレイスメントマップを作成します。

そのディスプレイスメントマップをZbrush上でスキャンメッシュにアサインしてゲームメッシュにベイクするのが目的です。

PhotoScanにより自動で生成されたテクスチャがコレです。

f:id:sgtech:20181224005905j:plain

PhotoScanで生成したテクスチャ

ご覧のとおりバラバラです。

 

ほくろやシミなどの余計な凹凸情報になりえるものは先にフラットにしておきます。

f:id:sgtech:20181224010044j:plain

 

前処理が済んだらPhotoshop上でハイパス等を使いディスプレイスメントマップを作成します。


次にZbrush上での作業にうつります。

f:id:sgtech:20181224005812j:plain

右側がZbrush上で、スキャンメッシュにディスプレイスメントマップを適用した状態です。

f:id:sgtech:20181224005526p:plain

ディスプレイスをかける前よりかなり細かい凹凸が表現できているのがわかりますでしょうか。

先ほど調整したゲームメッシュをインポートし、レイヤー分けしてからスキャンメッシュにディスプレイスメントを適応します。その凹凸情報を、projectALLを使って転写します。

スキャンメッシュにはエラーも多いので、この作業の前に事前にモーフターゲットを登録しておきprojectALLで投影したくない部分をマスクしてするなどして都合の悪い部分をフィルタリングしています。

その後、キャラクターによってはさらにスカルプトしてディティールアップを図ります。

後はSubdivisionの一番上と一番下をエクスポートし、XnormalやSubstance等のテクスチャベイカーを使ってタンジェントノーマルとAOを生成します。
人によってはディフューズもこの段階でスキャンメッシュからベイクします。

生成したテクスチャが…

f:id:sgtech:20181224005902j:plain

これですね。ちゃんとバラバラだったUVが繋がっています。

スキャンメッシュからベイクによって得たデータとなります。

ディフューズマップはベイクしたままというわけにはいかないのでさらに手を入れて調整します。

テクスチャの撮影時に陰影を消すようにはしているものの、どうしても取りきれない陰影が入ってしまいますので手作業で余計な陰影を除去します。

f:id:sgtech:20181224010047j:plain

少しわかりづらいかもしれませんが、除去前と除去後で陰影の差が少し緩和されています。
消しすぎると単調に見えるのでさじ加減に多少経験が必要な作業です。


シャイニネスマップ

シャイニネスマップは、ノーマルマップをキャビティマップに変換するアクションを使い作成したマップをオーバレイで5枚重ね共通のトーンカーブを適用した後、光って欲しいところ、欲しくないところを既定の値で塗り完成です。

すべてのキャラクターはUVが共通なので、実際はキャビティレイヤーの差し替えと個人で違う位置になる唇周りと眉毛周りの塗りの調整のみ行っています。

Substanceでも同様に上の一連の作業が組んであります。

トランスミッタンスマップ

つづいて肌透過用のトランスミッタンスマップの作成も紹介しましょう。

f:id:sgtech:20181224005522p:plain

顔モデルにスムースをかけた後に、白黒反転をしてアンビエントオクリュージョンをベイクしたものです。
このフロー自体が正しいものなのかは正直わかりませんが、コストと効率を鑑みて、最も効果的なフローでした。

処理軽減のため全てのキャラが同じテクスチャを使っています。

以上ですべてのテクスチャ作成が完了です。

 

仕上げにテクスチャの中間データはDDSを採用しているのでDDSで書き出します。

その際のインターフェイスが煩雑で多くの設定ができてしまうので、デザイナーが迷わないようにツールを用意しました。

リサイズのアルゴリズムとDDS保存形式を任意で選ぶことができます。

f:id:sgtech:20181224005737p:plain

f:id:sgtech:20181224005943j:plain

元々はガンマ補正、レベル補正の設定や、ラップモード設定、ミップマップ設定など細かく分かれていたのですが、それらの仕様が確定されてからは、混乱を避けるため全て非表示にして触れないようにしてあります。

細かいことですが、これによりかなりミスも少なくなりかなり効率的に作業できるようになりました。

その後いろいろ微調整*5して、完成したモデルですが…

f:id:sgtech:20181224005912j:plain

f:id:sgtech:20181224005909j:plain

全身

どうでしょう、島野さんに見えますか?

「龍が如くスタジオ」流のフローでこのクオリティの顔モデルが今では1日もあれば誰でも制作できるようになりました!

 

架空のキャラクター

ついでに架空キャラクターのフローについてもすこし触れてみましょう。

基本的に弊社の社員に協力してもらい、リアルなキャラクターを作成する場合のフローでほぼそのまま再現します。

その後、キャラクターの設定に合わせて修正します。

f:id:sgtech:20181224010040j:plain

このキャラクターは眉毛の角度や目つきの修正ぐらいしか行っていません。

設定によっては元の風貌からは別人になるケースもあり、特に重要度の高いキャラになるとコンセプト画で用意した顔に近づけるために原形をとどめなくなります。

その場合は、ほとんど肌のディフューズ以外は、使用しないのでデザイナーの腕の見せ所でしょうか。

 

次は顔モデリング以外の話を簡単にご紹介します。

 

顔のしわ

各キャラクターには顔の表情が変わったときに表出する皺を表現するための皺マップが用意されています。

下の画像はとあるキャラの顔の皺マップです。

f:id:sgtech:20181224005734p:plain

用意するマップはノーマルマップのみです。

皺用のディフューズやシャイニネス等は用意しておらず、頂点カラーの塗分けによってノーマルマップの表出を制御しています。

f:id:sgtech:20181224010212j:plain

皺表出用の頂点カラー

イベントムービー班が設定した表情のパターンに合わせて表出するようになっており、

デザイナーの作業負担軽減の為全キャラクター設定はほとんど同じです。


キャラ作成をサポートするツール群

ここまでモデルを作成するフローを説明してきましたが、ここからはそのフローの作業を軽減するツールについてお話したいと思います。

まずウェイトのセットアップツールです。DCCツールをSoftimage*6からMayaに切り替えた経緯上、Softimageと同じ感覚でMayaでのウェイト編集を出来るようにする必要がありました。

そこでまずはデザインサポートチームでウェイトのエディタを作成することになりました。

顔のポリゴンメッシュはWrapXによって生成されたものを使っていれば全て同じUV・同じ頂点番号なので、ウェイトセットアップは頂点番号かUVのポジションでウェイトをアサインします。

これで殆どの場合うまくいくのですが、目の形によって目を閉じた時に破綻したり眉毛の位置によっては眉毛が付いてこない、あるいは瞬きしたときに眉毛もついてくる等のバグが発生します。

Maya用のウェイトエディターを使って修正していきます。

f:id:sgtech:20181224005731p:plain

ウェイトのスムースや、ジョイントのロック、ウェイトを保持したままのエッジ切り直し、スキンモデルのデュプリケート、スキンモデルのコンバインなど沢山の便利なツールが用意されています。
このエディタが用意されてからはコンポーネントエディターの数倍速くウェイトの修正ができるようになりました。

 
WrapX

上記でチラホラ名前の挙がっていたツール、WrapXを紹介します。

WrapXとは大抵はDCCツール上で行うシュリンクラップを、高精度で行うスタンドアロンのアプリケーションです。
今は多機能な新バージョンもリリースしている古いツールですが、スクリプトで操作できるのが決め手でこのツールを長らく使用しています。

「龍が如く」プロジェクトでは、PhotoScanで取得した高精細なポリゴンモデルを、リトポロジも兼ねて一定のトポロジのメッシュに落とし込む目的で使用しています。

Pythonでスクリプトを書かないとカスタマイズができないのでデザイナーからしてみれば使いにくいツールですが、このツールを導入しあえてデザイナーが触れないようにしてリトポロジやポリゴンの削減をWrapXで吸収してしまえば、こだわりがちなデザイナーの工数も増えづらいのではないかという狙いもあります。

f:id:sgtech:20181224005858j:plain

WrapXでシュリンクラップを行います

左がベースメッシュで約5000頂点あります。
ベースメッシュとは、「龍が如く」プロジェクトにおいて素体として最初に作られるモデルで、ほぼすべてのキャラクターモデルはこのベースモデルを基準に作られています。
右がスキャンメッシュです。

まず、既存のPhotoScanで生成した顔のメッシュやテクスチャ、トポロジを共通にするためのベースモデルを読み込み、最初は三点、両目頭と人中にポイントを指定して大まかに頭部の位置とスケールを合わせます。

次に、詳細にシュリンクラップを行うため、位置合わせのためのポイントをPhotoScanのメッシュとベースモデル両方に指定していきます。

計算を除外するポリゴンも指定する事ができるので、ソースのメッシュが大きく欠けている場合も、ベースのモデルを大きく変形させる事なくシュリンクラップを行う事ができます。

設定が完了したら計算を行います。

その過程をGifにしてみました。

f:id:sgtech:20181224010051g:plain

シュリンクラップの様子(約40倍速)

最後にスキャンメッシュをシュリンクラップしたベースメッシュと、それに位置とスケールを合わせたスキャンメッシュをエクスポートして完了です。 

f:id:sgtech:20181224010206j:plain

WrapXをMayaから操作するツール

最初はWrapXのみでシュリンクラップの作業を行っていましたが、現在では手軽にシュリンクラップや編集が行えるようにMayaからコントロールできるツールを用意してあります。

こちらのツールでは、WrapXの鬼門であったフローの途中で指定していたポイントの編集を行う機能も追加しました。

顔のハイメッシュは、このWrapXのフローを採用することでまだ技術的に未熟なデザイナーでも比較的ハイスピード&ハイクオリティに作成できるようになり、トポロジを共通化することで面倒なウェイト関連の作業もストレスなく行う事ができるようになりました。

 LODの作成

ゲーム制作の辛いところはここからLODを作成しないといけないところですが、LOD作成もツール側である程度自動化してあります。 

現在既成のLOD作成ツールは「龍が如く」プロジェクトでは使用せず、クオリティ重視で手作業によりLODを作成しています。

ですが顔のLODは先ほどのトポロジの共通化によりボタン一つでクオリティを維持したまま生成できるツールが使えるのでLOD制作の時間はほとんどかかっていません。

そのほかのツール

「龍が如く」プロジェクトではほかにもリアルなキャラモデルを短時間で作り上げる為に、いろいろなフローを取り入れています。

しかし、「リアルにする為のフローを取り入れる=工数の増大につながる」ことが多いため、フローの採用とその際に増える工数の圧縮の為のツールの制作はセットで行うようにしています。

たとえばMarvelousDesignerで作成したデータのリトポロジ用のツールなどがあります。

 

f:id:sgtech:20181224005728p:plain

f:id:sgtech:20181224005725p:plain

MarverousDesignerは衣服などの3Dデータの作成や、シミュレーションを行えるアプリケーションです。
リアリティのある衣服の3Dモデルを作成するには最適なアプリケーションでしたがリトポロジが大きな課題でした。
この問題もデザインサポートチームでリトポロジーツールを作成し、さらにゲームエンジンにすぐに組み込めるようウェイトやマテリアルの設定、各種テクスチャの焼きつけなども簡易的に行えるようにしました。

他には

  • 今まで作成した大量の顔データをブレンドして新しい顔を作るツール
  • 写真を撮影するだけでPhotoScanにより全自動でスキャンメッシュが生成されるツール
  • そのPhotoScanで生成したスキャンメッシュを短時間でゲーム用のデータに落とし込むツール

など沢山あります。

Mayaはスクリプトを覚えてツールを作れるようになるとあちこち自動化・高速化ができてとても楽しいツールです。
私はデザイナーとしてセガに入社しましたが、今ではPythonでMayaのツールを作るのにすっかりハマっています。
このブログを読んでいるデザイナーなのにツールを作って自動化するのが大好きな方!クオリティの高い作品作りに懸命に取り組む弊社デザイナーたちの作業効率化に向けた環境づくりに、共に取り組んでいただけませんか?
もちろん、そうではない方も「龍が如くスタジオ」であんな芸能人やこんな有名人の方々と一緒にお仕事してみませんか?
「龍が如くスタジオ」は一緒にゲームを作ってくれる仲間を募集中です!

sega-games.co.jp

それでは皆さま、よいお年をお迎えください!


©SEGA

 

*1:「龍が如く」プロジェクトの方言で簡易的なフェイシャルアニメーションが適用されるスペックのキャラクター

*2:プロジェクターから発する光のパターン画像をデジタルカメラで撮影するという技術

*3:河童や小野ミチオなど

*4:シェーダーによって前後します

*5:企業秘密★

*6:つい最近まで「龍が如く」シリーズのキャラクター班はSoftimageで開発していました

Powered by はてなブログ