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

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

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

與“平滑鏡面高光”部分中實現的 Phong 反射模型相比,本節影像中的鏡面高光只是純白色,沒有任何其他顏色。此外,它們的邊界非常銳利。
我們可以透過計算 Phong 著色模型的鏡面反射項,並在鏡面反射項大於某個閾值(例如,最大強度的二分之一)時將片段顏色設定為鏡面反射顏色乘以(未衰減的)光源顏色來實現這種風格化的鏡面高光。
但是,如果不存在高光怎麼辦?通常,使用者會為此情況指定黑色鏡面反射顏色;但是,使用我們的方法會導致黑色高光。解決此問題的一種方法是考慮鏡面反射顏色的不透明度,並透過根據鏡面顏色的不透明度進行合成來在其他顏色上“混合”高光顏色。作為逐片段操作的 Alpha 混合在“透明度”部分進行了描述。但是,如果所有顏色在片段著色器中已知,則也可以在片段著色器中計算。
在下面的程式碼片段中,假設fragmentColor已經分配了一個顏色,例如,基於漫反射照明。然後,鏡面顏色_SpecColor乘以光源顏色_LightColor0根據鏡面顏色_SpecColor.a的不透明度在fragment_Color上進行混合。
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
* vec3(_LightColor0) * vec3(_SpecColor)
+ (1.0 - _SpecColor.a) * fragmentColor;
}
這是否足夠?如果您仔細觀察左側公牛的眼睛,您會看到兩對鏡面高光,也就是說,存在多個光源導致鏡面高光。在大多數教程中,我們透過帶有累加混合的第二次渲染傳遞來考慮額外的光源。但是,如果鏡面高光的顏色不應新增到其他顏色中,則不應使用累加混合。相反,使用具有(通常)不透明顏色的鏡面高光和具有透明片段的其他片段的 Alpha 混合是一個可行的解決方案。(有關 Alpha 混合的描述,請參見“透明度”部分。)

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

許多卡通著色器的特徵之一是在模型輪廓處使用特定顏色(通常是黑色,但也可能是其他顏色,例如上面的牛)繪製輪廓。
在著色器中實現這種效果有各種技術。Unity 3.3 附帶了一個卡通著色器,該著色器透過以輪廓顏色(透過沿表面法線向量方向移動頂點位置而放大)渲染放大模型的背面,然後在上面渲染正面來渲染這些輪廓。在這裡,我們使用另一種基於“輪廓增強”部分的技術:如果確定片段足夠靠近輪廓,則將其設定為輪廓顏色。這僅適用於光滑表面,並且會生成不同厚度的輪廓(這取決於視覺風格是優點還是缺點)。但是,至少輪廓的總體厚度應該可以透過著色器屬性控制。
我們完成了嗎?如果您仔細觀察驢子,您會發現它肚子和耳朵上的輪廓明顯比其他輪廓更厚。這傳達了未照亮區域;但是,厚度的變化是連續的。模擬這種效果的一種方法是讓使用者指定兩種總體輪廓厚度:一種用於完全照亮區域,另一種用於未照亮區域(根據 Phong 反射模型的漫反射反射項)。在這兩種極端之間,厚度引數可以進行插值(再次根據漫反射反射項)。但是,這使得輪廓依賴於特定的光源;因此,下面的著色器僅針對第一個光源渲染輪廓和漫反射照明,該光源通常應該是最重要的一個。所有其他光源僅渲染鏡面高光。
以下實現使用mix指令在_UnlitOutlineThickness(如果漫反射項的點積小於或等於0)和_LitOutlineThickness(如果點積為1)之間插值。對於從值a到另一個值b的線性插值,引數x在0到1之間,GLSL提供了內建函式mix(a, b, x)。然後將此插值的值用作閾值,以確定點是否足夠接近輪廓。如果是,則片段顏色設定為輪廓的顏色_OutlineColor。
if (dot(viewDirection, normalDirection)
< mix(_UnlitOutlineThickness, _LitOutlineThickness,
max(0.0, dot(normalDirection, lightDirection))))
{
fragmentColor =
vec3(_LightColor0) * vec3(_OutlineColor);
}
現在應該清楚,即使是上面的少數影像,對於忠實的實現也帶來了一些非常困難的挑戰。因此,下面的著色器只實現上面描述的一些特徵,而忽略了其他許多特徵。請注意,不同的顏色貢獻(漫反射光照、輪廓、高光)根據哪個應該遮擋哪個被賦予了不同的優先順序。您也可以將這些優先順序視為彼此疊加的不同層。
Shader "GLSL 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
GLSLPROGRAM
// User-specified properties
uniform vec4 _Color;
uniform vec4 _UnlitColor;
uniform float _DiffuseThreshold;
uniform vec4 _OutlineColor;
uniform float _LitOutlineThickness;
uniform float _UnlitOutlineThickness;
uniform vec4 _SpecColor;
uniform float _Shininess;
// The following built-in uniforms (except _LightColor0)
// are also defined in "UnityCG.glslinc",
// i.e. one could #include "UnityCG.glslinc"
uniform vec3 _WorldSpaceCameraPos;
// camera position in world space
uniform mat4 _Object2World; // model matrix
uniform mat4 _World2Object; // inverse model matrix
uniform vec4 _WorldSpaceLightPos0;
// direction to or position of light source
uniform vec4 _LightColor0;
// color of light source (from "Lighting.cginc")
varying vec4 position;
// position of the vertex (and fragment) in world space
varying vec3 varyingNormalDirection;
// surface normal vector in world space
#ifdef VERTEX
void main()
{
mat4 modelMatrix = _Object2World;
mat4 modelMatrixInverse = _World2Object; // unity_Scale.w
// is unnecessary because we normalize vectors
position = modelMatrix * gl_Vertex;
varyingNormalDirection = normalize(vec3(
vec4(gl_Normal, 0.0) * modelMatrixInverse));
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
}
#endif
#ifdef FRAGMENT
void main()
{
vec3 normalDirection = normalize(varyingNormalDirection);
vec3 viewDirection =
normalize(_WorldSpaceCameraPos - vec3(position));
vec3 lightDirection;
float attenuation;
if (0.0 == _WorldSpaceLightPos0.w) // directional light?
{
attenuation = 1.0; // no attenuation
lightDirection = normalize(vec3(_WorldSpaceLightPos0));
}
else // point or spot light
{
vec3 vertexToLightSource =
vec3(_WorldSpaceLightPos0 - position);
float distance = length(vertexToLightSource);
attenuation = 1.0 / distance; // linear attenuation
lightDirection = normalize(vertexToLightSource);
}
// default: unlit
vec3 fragmentColor = vec3(_UnlitColor);
// low priority: diffuse illumination
if (attenuation
* max(0.0, dot(normalDirection, lightDirection))
>= _DiffuseThreshold)
{
fragmentColor = vec3(_LightColor0) * vec3(_Color);
}
// higher priority: outline
if (dot(viewDirection, normalDirection)
< mix(_UnlitOutlineThickness, _LitOutlineThickness,
max(0.0, dot(normalDirection, lightDirection))))
{
fragmentColor =
vec3(_LightColor0) * vec3(_OutlineColor);
}
// 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
* vec3(_LightColor0) * vec3(_SpecColor)
+ (1.0 - _SpecColor.a) * fragmentColor;
}
gl_FragColor = vec4(fragmentColor, 1.0);
}
#endif
ENDGLSL
}
Pass {
Tags { "LightMode" = "ForwardAdd" }
// pass for additional light sources
Blend SrcAlpha OneMinusSrcAlpha
// blend specular highlights over framebuffer
GLSLPROGRAM
// User-specified properties
uniform vec4 _Color;
uniform vec4 _UnlitColor;
uniform float _DiffuseThreshold;
uniform vec4 _OutlineColor;
uniform float _LitOutlineThickness;
uniform float _UnlitOutlineThickness;
uniform vec4 _SpecColor;
uniform float _Shininess;
// The following built-in uniforms (except _LightColor0)
// are also defined in "UnityCG.glslinc",
// i.e. one could #include "UnityCG.glslinc"
uniform vec3 _WorldSpaceCameraPos;
// camera position in world space
uniform mat4 _Object2World; // model matrix
uniform mat4 _World2Object; // inverse model matrix
uniform vec4 _WorldSpaceLightPos0;
// direction to or position of light source
uniform vec4 _LightColor0;
// color of light source (from "Lighting.cginc")
varying vec4 position;
// position of the vertex (and fragment) in world space
varying vec3 varyingNormalDirection;
// surface normal vector in world space
#ifdef VERTEX
void main()
{
mat4 modelMatrix = _Object2World;
mat4 modelMatrixInverse = _World2Object; // unity_Scale.w
// is unnecessary because we normalize vectors
position = modelMatrix * gl_Vertex;
varyingNormalDirection = normalize(vec3(
vec4(gl_Normal, 0.0) * modelMatrixInverse));
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
}
#endif
#ifdef FRAGMENT
void main()
{
vec3 normalDirection = normalize(varyingNormalDirection);
vec3 viewDirection =
normalize(_WorldSpaceCameraPos - vec3(position));
vec3 lightDirection;
float attenuation;
if (0.0 == _WorldSpaceLightPos0.w) // directional light?
{
attenuation = 1.0; // no attenuation
lightDirection = normalize(vec3(_WorldSpaceLightPos0));
}
else // point or spot light
{
vec3 vertexToLightSource =
vec3(_WorldSpaceLightPos0 - position);
float distance = length(vertexToLightSource);
attenuation = 1.0 / distance; // linear attenuation
lightDirection = normalize(vertexToLightSource);
}
vec4 fragmentColor = vec4(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 =
vec4(_LightColor0.rgb, 1.0) * _SpecColor;
}
gl_FragColor = fragmentColor;
}
#endif
ENDGLSL
}
}
// The definition of a fallback shader should be commented out
// during development:
// Fallback "Specular"
}
這種著色器的一個問題是顏色之間的硬邊,這通常會導致明顯的鋸齒,尤其是在輪廓處。這可以透過使用smoothstep函式來提供更平滑的過渡來緩解。
恭喜你,你已經完成了本教程。我們已經看到了
- 什麼是卡通渲染、セルシェーディング和非真實感渲染。
- 一些非真實感渲染技術是如何應用於卡通渲染的。
- 如何在著色器中實現這些技術。
如果你還想了解更多
- 關於Phong反射模型和逐畫素光照,請閱讀“平滑鏡面高光”部分。
- 關於輪廓的計算,請閱讀“輪廓增強”部分。
- 關於混合,請閱讀“透明度”部分。
- 關於非真實感渲染技術,您可以閱讀Randi Rost 等人於 2009 年由 Addison-Wesley 出版的三版書籍“OpenGL 著色語言”第 18 章。