Cg 程式設計/Unity/卡通著色

本教程介紹了卡通著色(也稱為卡通渲染)作為非真實感渲染技術的示例。
它是關於光照的幾個教程之一,它超越了 Phong 反射模型。但是,它基於畫素級光照,使用 Phong 反射模型,如 “平滑鏡面高光”部分 中所述。如果您還沒有閱讀該教程,請先閱讀它。
非真實感渲染是計算機圖形學中一個非常寬泛的術語,它涵蓋所有渲染技術和視覺風格,這些技術和風格明顯且有意地不同於對物理物體的照片的外觀。示例包括陰影線、輪廓、線性透視的扭曲、粗略抖動、粗略顏色量化等。
卡通著色(或卡通渲染)是非真實感渲染技術的任何子集,用於實現三維模型的卡通或手繪外觀。

皮克斯的 John Lasseter 曾經在一次採訪中說過:“藝術挑戰技術,技術激勵藝術。” 許多傳統上用於描繪三維物體的視覺風格和繪製技術實際上很難在著色器中實現。但是,從根本上說沒有理由不去嘗試。
在為任何特定視覺風格實現一個或多個著色器時,應首先確定該風格的哪些特徵必須實現。這主要是對視覺風格示例進行精確分析的任務。如果沒有這樣的例子,通常很難確定風格的特徵。即使是掌握某種風格的藝術家也往往無法恰當地描述這些特徵;例如,因為他們不再意識到某些特徵,或者可能認為某些特徵是不必要的缺陷,不值得一提。
對於每個特徵,應確定是否以及如何準確地實現它們。一些特徵相當容易實現,另一些特徵對於程式設計師來說非常難以實現,或者對於 GPU 來說非常難以計算。因此,在 John Lasseter 上述引言的精神下,著色器程式設計師和(技術)藝術家之間的討論通常非常有意義,以決定要包含哪些特徵以及如何準確地再現它們。

與 “平滑鏡面高光”部分 中實現的 Phong 反射模型相比,本節圖片中的鏡面高光只是純白色,沒有任何其他顏色的新增。此外,它們具有非常清晰的邊界。
我們可以透過計算 Phong 著色模型的鏡面反射項,並在鏡面反射項大於某個閾值(例如,最大強度的二分之一)時,將片段顏色設定為鏡面反射顏色乘以(未衰減的)光源顏色來實現這種風格化鏡面高光。
但是,如果不應該有任何高光怎麼辦?通常,使用者會為此情況指定黑色鏡面反射顏色;但是,使用我們的方法會導致黑色高光。解決此問題的一種方法是考慮鏡面反射顏色的不透明度,並透過基於鏡面顏色的不透明度進行合成,將高光顏色“混合”到其他顏色上。作為 逐片段操作 的 Alpha 混合在 “透明度”部分 中進行了描述。但是,如果在片段著色器中知道所有顏色,也可以在片段著色器中計算它們。
在下面的程式碼片段中,假設 fragmentColor 已經分配了一個顏色,例如,基於漫射照明。然後根據鏡面顏色的不透明度 _SpecColor.a,將鏡面顏色 _SpecColor 乘以光源顏色 _LightColor0 混合到 fragmentColor 上。
if (dot(normalDirection, lightDirection) > 0.0
// light source on the right side?
&& attenuation * pow(max(0.0, dot(
reflect(-lightDirection, normalDirection),
viewDirection)), _Shininess) > 0.5)
// more than half highlight intensity?
{
fragmentColor = _SpecColor.a
* _LightColor0.rgb * _SpecColor.rgb
+ (1.0 - _SpecColor.a) * fragmentColor;
}
這足夠了嗎?如果您仔細觀察左側公牛的眼睛,您會看到兩對鏡面高光,即存在多個導致鏡面高光的燈光。在大多數教程中,我們透過第二個帶有疊加混合的渲染通道來考慮額外的光源。但是,如果鏡面高光的顏色不應新增到其他顏色,則不應使用疊加混合。相反,使用(通常)不透明顏色進行 Alpha 混合以獲得鏡面高光,以及使用透明片段以獲得其他片段將是一個可行的解決方案。(有關 Alpha 混合的描述,請參閱 “透明度”部分。)

左側公牛圖片中的漫射照明只包含兩種顏色:淺棕色用於照亮的皮毛,深棕色用於未照亮的皮毛。公牛其他部位的顏色與照明無關。
實現此方法的一種方法是,當 Phong 反射模型的漫射反射項達到某個閾值(例如,大於 0)時,使用完整的漫射反射顏色,否則使用第二種顏色。對於公牛的皮毛,這兩種顏色將不同;對於其他部位,它們將相同,因此在照亮區域和未照亮區域之間沒有視覺差異。對於閾值 _DiffuseThreshold 來切換從較暗的顏色 _UnlitColor 到較亮的顏色 _Color(乘以光源顏色 _LightColor0)的實現可能如下所示
float3 fragmentColor = _UnlitColor.rgb;
if (attenuation
* max(0.0, dot(normalDirection, lightDirection))
>= _DiffuseThreshold)
{
fragmentColor = _LightColor0.rgb * _Color.rgb;
}
這就是關於左側圖片中風格化漫射照明的全部嗎?仔細觀察就會發現,深棕色和淺棕色之間有一條淺色、不規則的線條。實際上,情況甚至更加複雜,深棕色有時不會覆蓋由上面描述的技術覆蓋的所有區域,有時它覆蓋的範圍更大,甚至超出了黑色輪廓。這為視覺風格增添了豐富的細節,營造出手繪的外觀。另一方面,在著色器中令人信服地再現這一點非常困難。

許多卡通著色器的特徵之一是在模型的輪廓線(通常為黑色,但也可能是其他顏色,例如上面的牛)上使用特定顏色的輪廓線。
在著色器中實現這種效果有多種技術。Unity 3.3 附帶了標準資源中的一個卡通著色器,該著色器透過渲染放大模型的背面(透過沿表面法向量方向移動頂點位置來放大)來渲染這些輪廓線,然後在其上渲染正面。在這裡,我們使用另一種基於 “輪廓增強”部分 的技術:如果確定片段足夠靠近輪廓線,則將其設定為輪廓線的顏色。這僅適用於光滑表面,並且會生成不同厚度的輪廓線(這取決於視覺風格是優點還是缺點)。但是,至少輪廓線的整體厚度應該可以透過著色器屬性進行控制。
我們完成了嗎?如果你仔細觀察驢子,你會發現它肚子和耳朵的輪廓比其他輪廓明顯更厚。這傳達了未照亮區域;然而,厚度的變化是連續的。模擬這種效果的一種方法是讓使用者指定兩種整體輪廓厚度:一種用於完全照亮區域,另一種用於未照亮區域(根據 Phong 反射模型的漫反射項)。在這兩種極端情況之間,厚度引數可以插值(同樣根據漫反射項)。然而,這使得輪廓依賴於特定光源;因此,以下著色器僅對第一個光源渲染輪廓和漫反射照明,該光源通常應該是最重要的一個。所有其他光源只渲染鏡面高光。
以下實現必須在 _UnlitOutlineThickness(如果漫反射項的點積小於或等於 0)和 _LitOutlineThickness(如果點積為 1)之間插值。對於從值 a 到另一個值 b 的線性插值,引數 x 在 0 到 1 之間,Cg 提供了內建函式 lerp(a, b, x)。然後使用插值後的值作為閾值來確定點是否足夠接近輪廓。如果是,則將片段顏色設定為輪廓顏色 _OutlineColor
if (dot(viewDirection, normalDirection)
< lerp(_UnlitOutlineThickness, _LitOutlineThickness,
max(0.0, dot(normalDirection, lightDirection))))
{
fragmentColor = _LightColor0.rgb * _OutlineColor.rgb;
}
現在應該很清楚,即使上面的幾張圖片也對忠實實現提出了非常困難的挑戰。因此,以下著色器只實現了上面描述的一些特徵,而忽略了許多其他特徵。請注意,不同的顏色貢獻(漫反射照明、輪廓、高光)根據哪些應該遮擋哪些而被賦予不同的優先順序。你也可以將這些優先順序視為彼此疊加的不同層。
Shader "Cg shader for toon shading" {
Properties {
_Color ("Diffuse Color", Color) = (1,1,1,1)
_UnlitColor ("Unlit Diffuse Color", Color) = (0.5,0.5,0.5,1)
_DiffuseThreshold ("Threshold for Diffuse Colors", Range(0,1))
= 0.1
_OutlineColor ("Outline Color", Color) = (0,0,0,1)
_LitOutlineThickness ("Lit Outline Thickness", Range(0,1)) = 0.1
_UnlitOutlineThickness ("Unlit Outline Thickness", Range(0,1))
= 0.4
_SpecColor ("Specular Color", Color) = (1,1,1,1)
_Shininess ("Shininess", Float) = 10
}
SubShader {
Pass {
Tags { "LightMode" = "ForwardBase" }
// pass for ambient light and first light source
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
uniform float4 _LightColor0;
// color of light source (from "Lighting.cginc")
// User-specified properties
uniform float4 _Color;
uniform float4 _UnlitColor;
uniform float _DiffuseThreshold;
uniform float4 _OutlineColor;
uniform float _LitOutlineThickness;
uniform float _UnlitOutlineThickness;
uniform float4 _SpecColor;
uniform float _Shininess;
struct vertexInput {
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct vertexOutput {
float4 pos : SV_POSITION;
float4 posWorld : TEXCOORD0;
float3 normalDir : TEXCOORD1;
};
vertexOutput vert(vertexInput input)
{
vertexOutput output;
float4x4 modelMatrix = unity_ObjectToWorld;
float4x4 modelMatrixInverse = unity_WorldToObject;
output.posWorld = mul(modelMatrix, input.vertex);
output.normalDir = normalize(
mul(float4(input.normal, 0.0), modelMatrixInverse).xyz);
output.pos = mul(UNITY_MATRIX_MVP, input.vertex);
return output;
}
float4 frag(vertexOutput input) : COLOR
{
float3 normalDirection = normalize(input.normalDir);
float3 viewDirection = normalize(
_WorldSpaceCameraPos - input.posWorld.xyz);
float3 lightDirection;
float attenuation;
if (0.0 == _WorldSpaceLightPos0.w) // directional light?
{
attenuation = 1.0; // no attenuation
lightDirection = normalize(_WorldSpaceLightPos0.xyz);
}
else // point or spot light
{
float3 vertexToLightSource =
_WorldSpaceLightPos0.xyz - input.posWorld.xyz;
float distance = length(vertexToLightSource);
attenuation = 1.0 / distance; // linear attenuation
lightDirection = normalize(vertexToLightSource);
}
// default: unlit
float3 fragmentColor = _UnlitColor.rgb;
// low priority: diffuse illumination
if (attenuation
* max(0.0, dot(normalDirection, lightDirection))
>= _DiffuseThreshold)
{
fragmentColor = _LightColor0.rgb * _Color.rgb;
}
// higher priority: outline
if (dot(viewDirection, normalDirection)
< lerp(_UnlitOutlineThickness, _LitOutlineThickness,
max(0.0, dot(normalDirection, lightDirection))))
{
fragmentColor = _LightColor0.rgb * _OutlineColor.rgb;
}
// highest priority: highlights
if (dot(normalDirection, lightDirection) > 0.0
// light source on the right side?
&& attenuation * pow(max(0.0, dot(
reflect(-lightDirection, normalDirection),
viewDirection)), _Shininess) > 0.5)
// more than half highlight intensity?
{
fragmentColor = _SpecColor.a
* _LightColor0.rgb * _SpecColor.rgb
+ (1.0 - _SpecColor.a) * fragmentColor;
}
return float4(fragmentColor, 1.0);
}
ENDCG
}
Pass {
Tags { "LightMode" = "ForwardAdd" }
// pass for additional light sources
Blend SrcAlpha OneMinusSrcAlpha
// blend specular highlights over framebuffer
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
uniform float4 _LightColor0;
// color of light source (from "Lighting.cginc")
// User-specified properties
uniform float4 _Color;
uniform float4 _UnlitColor;
uniform float _DiffuseThreshold;
uniform float4 _OutlineColor;
uniform float _LitOutlineThickness;
uniform float _UnlitOutlineThickness;
uniform float4 _SpecColor;
uniform float _Shininess;
struct vertexInput {
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct vertexOutput {
float4 pos : SV_POSITION;
float4 posWorld : TEXCOORD0;
float3 normalDir : TEXCOORD1;
};
vertexOutput vert(vertexInput input)
{
vertexOutput output;
float4x4 modelMatrix = unity_ObjectToWorld;
float4x4 modelMatrixInverse = unity_WorldToObject;
output.posWorld = mul(modelMatrix, input.vertex);
output.normalDir = normalize(
mul(float4(input.normal, 0.0), modelMatrixInverse).rgb);
output.pos = mul(UNITY_MATRIX_MVP, input.vertex);
return output;
}
float4 frag(vertexOutput input) : COLOR
{
float3 normalDirection = normalize(input.normalDir);
float3 viewDirection = normalize(
_WorldSpaceCameraPos - input.posWorld.rgb);
float3 lightDirection;
float attenuation;
if (0.0 == _WorldSpaceLightPos0.w) // directional light?
{
attenuation = 1.0; // no attenuation
lightDirection = normalize(_WorldSpaceLightPos0.xyz);
}
else // point or spot light
{
float3 vertexToLightSource =
_WorldSpaceLightPos0.xyz - input.posWorld.xyz;
float distance = length(vertexToLightSource);
attenuation = 1.0 / distance; // linear attenuation
lightDirection = normalize(vertexToLightSource);
}
float4 fragmentColor = float4(0.0, 0.0, 0.0, 0.0);
if (dot(normalDirection, lightDirection) > 0.0
// light source on the right side?
&& attenuation * pow(max(0.0, dot(
reflect(-lightDirection, normalDirection),
viewDirection)), _Shininess) > 0.5)
// more than half highlight intensity?
{
fragmentColor =
float4(_LightColor0.rgb, 1.0) * _SpecColor;
}
return fragmentColor;
}
ENDCG
}
}
Fallback "Specular"
}
這種著色器的一個問題是顏色之間的硬邊,這通常會導致明顯的走樣,特別是在輪廓處。這可以透過使用 smoothstep 函式來提供更平滑的過渡來緩解。
恭喜,你已經完成了本教程。我們看到了
- 什麼是卡通著色、漫畫著色和非真實感渲染。
- 一些非真實感渲染技術如何在卡通著色中使用。
- 如何在著色器中實現這些技術。
如果你還想了解更多
- 關於 Phong 反射模型和逐畫素照明,你應該閱讀 “平滑鏡面高光”部分。
- 關於輪廓的計算,你應該閱讀 “輪廓增強”部分。
- 關於混合,你應該閱讀 “透明度”部分。
- 關於非真實感渲染技術,你可以閱讀 Randi Rost 等人於 2009 年由 Addison-Wesley 出版發行的《OpenGL 著色語言》(第 3 版)的第 18 章。