Unity—卡通渲染實現


效果展示:

31516

原模型:

image-20220102233701399

簡單分析

卡通渲染又叫非真實渲染(None-Physical Rendering-NPR),一般日漫里的卡通風格有幾個特點:

人物有描邊

有明顯的陰影分界線,沒有太平滑的過渡

以下就根據這兩點來實現卡渲效果;

描邊

法線外擴

實現描邊方式多種,比如卷積區分邊界;

這里使用更簡單的兩個Pass,一個只用純色畫背面,利用法線外擴頂點,根據深度的不同這個純色的背面會被顯示出來,同時又不會遮擋正面;

Pass
{
    Tags {"LightMode"="ForwardBase"}
	//裁剪正面,只畫背面
    Cull Front
    
    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #include "UnityCG.cginc"

    half _OutlineWidth;
    half4 _OutLineColor;

    struct a2v 
    {
        float4 vertex : POSITION;
        float3 normal : NORMAL;
        float2 uv : TEXCOORD0;
        float4 vertColor : COLOR;
        float4 tangent : TANGENT;
    };

    struct v2f
    {
        float4 vertColor : TEXCOORD0;
        float4 pos : SV_POSITION;
    };

    v2f vert (a2v v) 
    {
        v2f o;
        UNITY_INITIALIZE_OUTPUT(v2f, o);
        
        //頂點沿着法線方向外擴
        o.pos = UnityObjectToClipPos(float4(v.vertex.xyz + v.normal * _OutlineWidth * 0.1 ,1));
        
        o.vertColor = fixed4(v.vertColor.rgb,1.0);
        return o;
    }

    half4 frag(v2f i) : SV_TARGET 
    {
        return half4(_OutLineColor.rgb * i.vertColor.rgb, 0);
    }
    ENDCG
}

細節處理(坑)

細節處理前后對比:

42

  • 攝像機遠近邊緣線粗細不同

    由於世界坐標系下做外擴,攝像機里物體遠近會影響法線外擴的多少;

    解決方案,在NDC坐標系下法線外擴;

    //頂點着色器替換以下代碼
    float4 pos = UnityObjectToClipPos(v.vertex);
    
    //攝像機空間法線
    float3 viewNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal.xyz);
    
    //將法線變換到NDC空間,投影空間*W分量
    float3 ndcNormal = normalize(TransformViewToProjection(viewNormal.xyz)) * pos.w;
    
    //xy兩方向外擴
    pos.xy += 0.01 * _OutlineWidth * ndcNormal.xy * v.vertColor.a;
    o.pos = pos;
    
  • 上下和左右邊緣線粗細不同

    NDC空間是正方形,而視口寬高比是長方體,導致描邊上下和左右的粗細不統一;

    解放方案,根據屏幕寬高比縮放法線再外擴;

    //將近裁剪面右上角位置的頂點變換到觀察空間
    //unity_CameraInvProjection攝像機矩陣逆矩陣,UNITY_NEAR_CLIP_VALUE近截面值,DX:0,OpenGL-1.0;_ProjectionParams.y攝像機近截面
    float4 nearUpperRight = mul(unity_CameraInvProjection, float4(1, 1, UNITY_NEAR_CLIP_VALUE, _ProjectionParams.y));
    
    //求得屏幕寬高比
    float aspect = abs(nearUpperRight.y / nearUpperRight.x);
    ndcNormal.x *= aspect;
    
  • 頂點重合法線不連續

    模型頂點重合時會出現多條法線,在不同的面上法線不同導致描邊不連續;

    解決方案,修改模型頂點數據,同頂點多條法線求平均值;

    需要和美工協商修改模型數據,這里寫了腳本臨時修改模型數據;

    public class PlugTangentTools
    {
        [MenuItem("Tools/模型平均法線寫入切線數據")]
        public static void WirteAverageNormalToTangentToos()
        {
            MeshFilter[] meshFilters = Selection.activeGameObject.GetComponentsInChildren<MeshFilter>();
            foreach (var meshFilter in meshFilters)
            {
                Mesh mesh = meshFilter.sharedMesh;
                WirteAverageNormalToTangent(mesh);
            }
    
            SkinnedMeshRenderer[] skinMeshRenders = Selection.activeGameObject.GetComponentsInChildren<SkinnedMeshRenderer>();
            foreach (var skinMeshRender in skinMeshRenders)
            {
                Mesh mesh = skinMeshRender.sharedMesh;
                WirteAverageNormalToTangent(mesh);
            }
            Debug.Log("重合頂點平均法線寫入成功");
        }
    
        private static void WirteAverageNormalToTangent(Mesh mesh)
        {
            var averageNormalHash = new Dictionary<Vector3, Vector3>();
            for (var j = 0; j < mesh.vertexCount; j++)
            {
                if (!averageNormalHash.ContainsKey(mesh.vertices[j]))
                {
                    averageNormalHash.Add(mesh.vertices[j], mesh.normals[j]);
                }
                else
                {
                    averageNormalHash[mesh.vertices[j]] =
                        (averageNormalHash[mesh.vertices[j]] + mesh.normals[j]).normalized;
                }
            }
    
            var averageNormals = new Vector3[mesh.vertexCount];
            for (var j = 0; j < mesh.vertexCount; j++)
            {
                averageNormals[j] = averageNormalHash[mesh.vertices[j]];
            }
    
            var tangents = new Vector4[mesh.vertexCount];
            for (var j = 0; j < mesh.vertexCount; j++)
            {
                tangents[j] = new Vector4(averageNormals[j].x, averageNormals[j].y, averageNormals[j].z, 0);
            }
            mesh.tangents = tangents;
        }
    }
    

ps:利用模型頂點的四個通道RGBA——對描邊粗細顯影相機距離縮放進行精細控制,需要美工配合;

着色

減少色階

二分法

將有陰影和沒陰影的地方做明顯的區分;

half4 frag(v2f i) : SV_TARGET 
{
    half4 col = 1;
    half4 mainTex = tex2D(_MainTex, i.uv);
    half3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
    half3 worldNormal = normalize(i.worldNormal);
    half3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);

    //半蘭伯特光照模型
    half halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;
    
    //_ShadowRange區分陰影范圍,_ShadowSmooth控制分界線的柔和程度,求出ramp值(百分比)
    half ramp = smoothstep(0, _ShadowSmooth, halfLambert - _ShadowRange);

    //根據ramp值插值取樣,將陰影和main顏色混合
    half3 diffuse = lerp(_ShadowColor, _MainColor, ramp);
    diffuse *= mainTex;
    col.rgb = _LightColor0 * diffuse;
    return col;
}

image-20220103173035747

Ramp貼圖

使用明顯分界的色階圖來取樣,使陰影有明顯的分界線;

邏輯和二分一樣,只是多加個幾個色階;

33

//_ShadowRange范圍取樣Ramp貼圖
half ramp =  tex2D(_RampTex, float2(saturate(halfLambert - _ShadowRange), 0.5)).r;

image-20220103171837758

高光色階

卡渲高光和陰影一樣,和周圍色塊有明顯的分界線;

half3 specular = 0;
half3 halfDir = normalize(worldLightDir + viewDir);
half NdotH = max(0, dot(worldNormal, halfDir));
//_SpecularGloss控制高光光澤度
half SpecularSize = pow(NdotH, _SpecularGloss);

//_SpecularRange高光范圍,_SpecularMulti強度,在范圍內顯示高光有明顯分界
if (SpecularSize >= 1 - _SpecularRange)
{
	specular = _SpecularMulti * _SpecularColor;
}

12

ilmTexture貼圖

《GUILTY GEAR Xrd》中使用的方法,又叫Threshold貼圖;

貼圖的R通道控制漫反射的陰影閾值,G通道控制高光強度,B通道控制高光范圍;

需要和美工配合,沒貼圖就不測了;

總之萬物皆可用貼圖來傳遞信息,rgba代表什么意思可以自行做各種trick;

half4 frag (v2f i) : SV_Target
{
	half4 col = 0;
	half4 mainTex = tex2D (_MainTex, i.uv);
    //取樣ilmTexture
	half4 ilmTex = tex2D (_IlmTex, i.uv);
	half3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
	half3 worldNormal = normalize(i.worldNormal);
	half3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
	
    //漫反射+陰影
	half3 diffuse = 0;
	half halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;
    //g通道控制高光強度
	half threshold = (halfLambert + ilmTex.r) * 0.5;
	half ramp = saturate(_ShadowRange  - threshold); 
	ramp =  smoothstep(0, _ShadowSmooth, ramp);
	diffuse = lerp(_MainColor, _ShadowColor, ramp);
	diffuse *= mainTex.rgb;

	half3 specular = 0;
	half3 halfDir = normalize(worldLightDir + viewDir);
	half NdotH = max(0, dot(worldNormal, halfDir));
	half SpecularSize = pow(NdotH, _SpecularGloss);
    //b通道控制高光遮罩
	half specularMask = ilmTex.b;
	if (SpecularSize >= 1 - specularMask * _SpecularRange)
	{
        //g控制高光強度
		specular = _SpecularMulti * (ilmTex.g) * _SpecularColor;
	}

	col.rgb = (diffuse + specular) * _LightColor0.rgb;
	return col;
}

【翻譯】西川善司「實驗做出的游戲圖形」「GUILTY GEAR Xrd -SIGN-」中實現的「純卡通動畫的實時3D圖形」的秘密,前篇(1)

【翻譯】西川善司「實驗做出的游戲圖形」「GUILTY GEAR Xrd -SIGN-」中實現的「純卡通動畫的實時3D圖形」的秘密,前篇(2)

【翻譯】西川善司的「實驗做出的游戲圖形」「GUILTY GEAR Xrd -SIGN-」中實現的「純卡通動畫的實時3D圖形」的秘密,后篇

邊緣泛光

三渲二加點邊緣泛光會增加立體感,讓畫質更真實;效果如下;

img

__RimMin、_RimMax控制邊緣泛光范圍;

smoothstep使過渡平緩;再乘以RimColor,alpha控制強度;

half f =  1.0 - saturate(dot(viewDir, worldNormal));
half rim = smoothstep(_RimMin, _RimMax, f);
rim = smoothstep(0, _RimSmooth, rim);
half3 rimColor = rim * _RimColor.rgb *  _RimColor.a;
col.rgb = (diffuse + specular + rimColor) * _LightColor0.rgb;

mask遮罩圖

用一張貼圖來修正邊緣泛光的效果;

邊緣光的計算使用的是法線點乘視線。在物體的法線和視線垂直的時候,邊緣光會很強。在球體上不會有問題,但是在一些有平面的物體,當平面和視線接近垂直的時候,會導致整個平面都有邊緣光。這會讓一些不該有邊緣光的地方出現邊緣光。

img

屏幕后效

post-processing官方組件中有bloom效果;

原理:提取圖像中較亮區域,存儲在紋理中,使用高斯模糊模擬光線擴散效果,將該紋理和原圖像混合;過程比較復雜,后面寫屏幕后期效果再分析吧;

完整Shader:

Shader "Unlit/CelRenderFull"
{
	Properties
	{
		_MainTex ("MainTex", 2D) = "white" {}
        _IlmTex ("IlmTex", 2D) = "white" {}

		[Space(20)]
		_MainColor("Main Color", Color) = (1,1,1)
		_ShadowColor ("Shadow Color", Color) = (0.7, 0.7, 0.7)
		_ShadowSmooth("Shadow Smooth", Range(0, 0.03)) = 0.002
		_ShadowRange ("Shadow Range", Range(0, 1)) = 0.6

		[Space(20)]
		_SpecularColor("Specular Color", Color) = (1,1,1)
		_SpecularRange ("Specular Range",  Range(0, 1)) = 0.9
        _SpecularMulti ("Specular Multi", Range(0, 1)) = 0.4
		_SpecularGloss("Sprecular Gloss", Range(0.001, 8)) = 4

		[Space(20)]
		_OutlineWidth ("Outline Width", Range(0, 1)) = 0.24
        _OutLineColor ("OutLine Color", Color) = (0.5,0.5,0.5,1)
			
		[Space(20)]
		_RimMin	("imMin",float) = 1.0
		_RimMax	("RimMax",float) = 2.0
		_RimSmooth("RimSmooth",Range(0.0,1))=0.5
		_RimColor("RimColor",Color) = (1,1,1,1)
	}

	SubShader
	{
		Pass
		{
			Tags { "LightMode"="ForwardBase"}

			CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
			#pragma multi_compile_fwdbase
			
			#include "UnityCG.cginc"
			#include "Lighting.cginc"
            #include "AutoLight.cginc"

            sampler2D _MainTex; 
			float4 _MainTex_ST;
            sampler2D _IlmTex; 
			float4 _IlmTex_ST;

			half3 _MainColor;
			half3 _ShadowColor;
			half _ShadowSmooth;
			half _ShadowRange;
			
			half3 _SpecularColor;
			half _SpecularRange;
        	half _SpecularMulti;
			half _SpecularGloss;

			half _RimMin;		
			half _RimMax;
			half _RimSmooth;
			fixed4 _RimColor;

			struct a2v
			{
				float4 vertex : POSITION;
				float2 uv : TEXCOORD0;
				float3 normal : NORMAL;
			};

			struct v2f
			{
				float4 pos : SV_POSITION;
				float2 uv : TEXCOORD0;	
				float3 worldNormal : TEXCOORD1;
				float3 worldPos : TEXCOORD2; 
			};
			
			v2f vert (a2v v)
			{
				v2f o = (v2f)0;
				o.pos = UnityObjectToClipPos(v.vertex);
				o.uv = TRANSFORM_TEX(v.uv, _MainTex);
				o.worldNormal = UnityObjectToWorldNormal(v.normal);
				o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
				return o;
			}
			
			half4 frag (v2f i) : SV_Target
			{
				half4 col = 0;
				half4 mainTex = tex2D (_MainTex, i.uv);
				half4 ilmTex = tex2D (_IlmTex, i.uv);
				half3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
				half3 worldNormal = normalize(i.worldNormal);
				half3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);

				half3 diffuse = 0;
				half halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;
				half threshold = (halfLambert + ilmTex.g) * 0.5;
				half ramp = saturate(_ShadowRange  - threshold); 
				ramp =  smoothstep(0, _ShadowSmooth, ramp);
				diffuse = lerp(_MainColor, _ShadowColor, ramp);
				diffuse *= mainTex.rgb;

				half3 specular = 0;
				half3 halfDir = normalize(worldLightDir + viewDir);
				half NdotH = max(0, dot(worldNormal, halfDir));
				half SpecularSize = pow(NdotH, _SpecularGloss);
				half specularMask = ilmTex.b;
				if (SpecularSize >= 1 - specularMask * _SpecularRange)
				{
					specular = _SpecularMulti * (ilmTex.r) * _SpecularColor;
				}

				half f =  1.0 - saturate(dot(viewDir, worldNormal));
				half rim = smoothstep(_RimMin, _RimMax, f);
				rim = smoothstep(0, _RimSmooth, rim);
				half3 rimColor = rim * _RimColor.rgb *  _RimColor.a;
				col.rgb = (diffuse + specular + rimColor) * _LightColor0.rgb;

				return col;
			}
			ENDCG
		}

        Pass
	    {
	        Tags {"LightMode"="ForwardBase"}
			 
            Cull Front
            
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            half _OutlineWidth;
            half4 _OutLineColor;

            struct a2v 
	        {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float2 uv : TEXCOORD0;
                float4 vertColor : COLOR;
                float4 tangent : TANGENT;
            };

            struct v2f

	        {
                float4 vertColor : TEXCOORD0;
                float4 pos : SV_POSITION;
            };


            v2f vert (a2v v) 
	        {
                v2f o;
		        UNITY_INITIALIZE_OUTPUT(v2f, o);
                
                float4 pos = UnityObjectToClipPos(v.vertex);
                float3 viewNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.tangent.xyz);
                float3 ndcNormal = normalize(TransformViewToProjection(viewNormal.xyz)) * pos.w;
				

                float4 nearUpperRight = mul(unity_CameraInvProjection, float4(1, 1, UNITY_NEAR_CLIP_VALUE, _ProjectionParams.y));
                float aspect = abs(nearUpperRight.y / nearUpperRight.x);
                ndcNormal.x *= aspect;

                pos.xy += 0.01 * _OutlineWidth * ndcNormal.xy * v.vertColor.a;
                o.pos = pos;
                o.vertColor = fixed4(v.vertColor.rgb,1.0);
                return o;
            }

            half4 frag(v2f i) : SV_TARGET 
	        {
                return half4(_OutLineColor.rgb * i.vertColor.rgb, 0);
            }
            ENDCG
        }
	}
	FallBack Off
}


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM