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 章。