本系列文章主要翻译和参考自《Real-Time 3D Rendering with DirectX and HLSL》一书(感谢原书作者),同时会加上一点个人理解和拓展,文章中如有错误,欢迎指正。
这里是书中的代码和资源。
本文所有的环境和工具使用都基于之前的文章,如有不明白的地方请先参考本系列之前的几篇文章。
本文索引:
在现实世界中,没有光我们将看不见任何东西,你所看见的物体或者是反射了光源的光或者是本身就能自发光。在计算机渲染的过程中,你将模拟灯光与物体的交互,并以此增加3D物体表面的细节。但是灯光的相互影响是一个非常复杂的过程,在目前的技术中并不能达到在一个可交互的帧率范围内进行这样大量的重复计算。因此,一般会采用一种近似算法,用一种描述灯光与3D模型如何交互的灯光模型来为你的感官增加更多可感受的细节。这篇文章中将介绍一些基础的光照模型。
不同的表面会以不同的方式反射光。镜面会将光线以与入射光相同角度的反方向反射出去。漫反射表面则会将入射光均等地反射到各个方向。
目前最简单通用的模拟漫反射的光照模型应该是兰伯特余弦定理(Lambert’s cosine law)。兰伯特余弦定理:模型表面的明亮度直接取决于光线向量(light vector)和表面法线两个向量将夹角的余弦值。光线向量是指这个点到光从哪个方向射入,表面法线则定义了这个表面的朝向。如下图所示:
两个向量间的夹角可以通过两个向量点乘的公式计算得出。表面法线可以通过两条边叉乘得出,通常情况,表面法线信息会在3D模型中直接提供出来。下面我们来讨论下如何得到光线向量。
在3D图形学中,有三种常见的光线类型:平行光(directional lights),点光源(point lights)以及聚光灯(spotlights)。平行光所代表的光源是那种具有无限远距离的光源,这种光源对你场景中的模型并没有具体位置。正因如此,这类型的光线总是以平行的方式到达每个物体的表面,他们都以同样的方向进行入射。太阳光就是这类型光源的很好的例子,虽然太阳并不是严格意义上有无限远距离的光源。但他与地球的距离已经足够让你没有办法分辨每一束光的入射方向。如下图所示为平行光:
想要模型化一个平行光,你只需要知道一个三维向量上光线的来源方向。你也可以在这个光源信息中加入颜色和强度信息,就想在这篇文章中环境光中所做的那样。下面的代码展示了如何用单一的平行光渲染出效果。
代码段Listing 6.2 DiffuseLighting.fx
#include "include\\Common.fxh"
/*************** Resources ***************/
cbuffer CBufferPerFrame
{
float4 AmbientColor : AMBIENT
<
string UIName = "Ambient Light";
string UIWidget = "Color";
> = {1.0f, 1.0f, 1.0f, 0.0f};
float4 LightColor : COLOR
<
string Object = "LightColor0";
string UIName = "Light Color";
string UIWidget = "Color";
> = {1.0f, 1.0f, 1.0f, 1.0f};
float3 LightDirection : DIRECTION
<
string Object = "DirectionalLight0";
string UIName = "Light Direction";
string Space = "World";
> = {0.0f, 0.0f, -1.0f};
}
cbuffer CBufferPerObject
{
float4x4 WorldViewProjection : WORLDVIEWPROJECTION <string UIWidget="None";>;
float4x4 World : WORLD <string UIWidget="None";>;
}
Texture2D ColorTexture
<
string ResourceName = "default_color.dds";
string UIName = "Color Texture";
string ResourceType = "2D";
>;
SamplerState ColorSampler
{
Filter = MIN_MAG_MIP_LINEAR;
AddressU = WRAP;
AddressV = WRAP;
};
RasterizerState DisableCulling
{
CullMode = NONE;
};
/*************** Data Structures ***************/
struct VS_INPUT
{
float4 ObjectPosition : POSITION;
float2 TextureCoordinate : TEXTCOORD;
float3 Normal : NORMAL;
};
struct VS_OUTPUT
{
float4 Position : SV_Position;
float3 Normal : NORMAL;
float2 TextureCoordinate : TEXTCOORD0;
float3 LightDirection : TEXCOORD1;
};
/*************** Vertex Shader ***************/
VS_OUTPUT Vertex_shader(VS_INPUT IN)
{
VS_OUTPUT OUT = (VS_OUTPUT)0;
OUT.Position = mul(IN.ObjectPosition, WorldViewProjection);
OUT.TextureCoordinate = get_corrected_texture_coordinate(IN.TextureCoordinate);
OUT.Normal = normalize(mul(float4(IN.Normal, 0), World).xyz);
return OUT;
}
/*************** Pixel Shader ***************/
float4 pixel_shader(VS_OUTPUT IN) : SV_Position
{
float4 OUT = (float4)0;
float3 normal = normalize(IN.Normal);
float3 lightDirection = normalize(IN.LightDirection);
float n_dot_1 = dot(lightDirection, normal);
float4 color = ColorTexture.Sample(ColorSampler, IN.TextureCoordinate);
float3 ambient = AmbientColor.rgb * AmbientColor.a * color.rgb;
float3 diffuse = (float3)0;
if(n_dot_1 > 0)
{
diffuse = LightColor.rgb * LightColor.a * n_dot_1 * color.rgb;
}
OUT.rgb = ambient + diffuse;
OUT.a = color.a;
return OUT;
}
/*************** Techniques ***************/
technique10 main10
{
pass p0
{
SetVertexShader(CompileShader(vs_4_0, vertex_shader()));
SetGeometryShader(NULL);
SetPixelShader(CompileShader(ps_4_0, pixel_shader()));
SetRasterizerState(DisableCulling);
}
}
DiffuseLighting.fx文件的第一行以C语言风格的代码引入一个常用的效果函数库文件。你需要在你的项目中新建一个include文件夹并将这个文件添加到文件夹下。下面的代码段展示了这个文件中的内容。注意引入文件所使用的是双引号,并且FLIP_TEXTURE_Y宏定义和get_corrected_texture_coordinate()函数已经转移到这个文件中实现了。
代码段Listing 6.3 Common.fxh
#ifndef _COMMON_FXH
#define _COMMON_FXH
/************* Constants *************/
#define FLIP_TEXTURE_Y 1
/************* Utility Functions *************/
float2 get_corrected_texture_coordinate(float2 textureCoordinate)
{
#if FLIP_TEXTURE_Y
return float2(textureCoordinate.x, 1.0 - textureCoordinate.y);
#else
return textureCoordinate;
#endif
}
float3 get_vector_color_contribution(float4 light, float3 color)
{
// Color (.rgb) * Intensity (.a)
return light.rgb * light.a * color;
}
float3 get_scalar_color_contribution(float4 light, float color)
{
// Color (.rgb) * Intensity (.a)
return light.rgb * light.a * color;
}
#endif /* _COMMON_FXH */
接下来在CBufferPerFrame中也加入了新成员:LightColor和LightDirection。LightColor和AmbientColor有一样的函数体,分别代表了平行光的颜色和强度。LightDirection中保存了光源在世界空间中的方向。这两个新成员同样也关联了新的对象注解。这两个注解说明这两个成员也可以被关联到场景对象中,也就是说,你可以在FX Composer的属性面板中通过注解字符串找到对应的属性并进行调整,关于怎么调整将在这篇文章的稍后一部分中介绍[(5) Diffuse Lighting Output)]。
CBufferPerObject中也加入了新成员World。这个成员与VS_INPUT结构体中的新成员normal密切相关。表面法线方向一开始是相对于本地坐标系的,就像顶点数据一样。你使用法线向量和光向量相乘得到这个像素的光强度时,这个光向量却是相对于世界坐标系的,因此必须把法线向量也转换成在世界坐标系下的,world这个矩阵就是用来做这种转换的。不能用WorldViewProjection矩阵做转换是因为这个矩阵会将向量转换为齐次坐标空间下的。在这里需要注意World这个矩阵也可能会将向量在原来的基础上进行缩放,而我们需要的法线是单位化向量,因此在经过这个矩阵的转换后还需要将法线再单位化一次。
VS_OUTPUT结构体中增加了两个新成员:Normal和LightDirection。Normal常量用于传输经过计算的表面法线值。LightDirection,这个成员是由于你在像素着色器中需要的是从表面到光源的方向,而从顶点着色器的输入参数中获得的是光源到表面的着色器。因此,你需要在顶点着色器中将其进行转换,当然,你也可以在CPU端转好了再传过来(最好是这样)。
关于顶点着色器的输出 |
细心的童鞋可以发现,顶点着色器的输出结构中LightDirection这个成员所关联的语义是TEXCOORD1。为什么一个方向型的float3常量会和TEXCOORD这样的语义相关联呢?又为什么TEXCOORD这个语义后面的数字是1?可不可以是2、3、4甚至5呢? |
以下为笔者自己的理解。 在结构体里面定义的这些常量,不一定每一个都有具体的语义和他相对应,在没有的情况下可以选择TEXCOORD这个语义,因为他是float4类型的,所以当然float3类型的也可以通过他进行传递。那么再来是第二个问题,TEXCOORD在官方SDK中的定义是TEXCOORD[n],可见,后面的这个数字是任意的,也可以没有,加上不同的数字只是为了与不同的常量相关联,这样,在传递数据的时候才能区分开来。 |
关于HLSL语义方面的内容可查看参考链接【1】中提供的资料。 |
这部分将介绍下加入了漫反射光后像素着色器中多了些什么。
代码段Listing6.4 DiffuseLighting.fx文件中的像素着色器
float4 pixel_shader(VS_OUTPUT IN) : SV_Target
{
float4 OUT = (float4)0;
float3 normal = normalize(IN.Normal);
float3 lightDirection = normalize(IN.LightDirection);
float n_dot_1 = dot(lightDirection, normal);
float4 color = ColorTexture.Sample(ColorSampler, IN.TextureCoordinate);
float3 ambient = AmbientColor.rgb * AmbientColor.a * color.rgb;
float3 diffuse = (float3)0;
if(n_dot_1 > 0)
{
diffuse = LightColor.rgb * LightColor.a * n_dot_1 * color.rgb;
}
OUT.rgb = ambient + diffuse;
OUT.a = color.a;
return OUT;
}
首先,为了更清晰的描述最终的颜色值还加入了漫反射光的影响,将ambient的计算与最终输出的OUT像素值分开。
其次,将传入的Normal和LightDirection向量都进行了单位化,这是因为从光栅阶段传过来的这些数据可能已经经过处理,已经不是单位化的了。这里的错误很容易被忽略,因为这只是视觉效果上的差异。当运行的时候发现报错了,也很可能会将这些错误忽略,所以这个步骤非常重要。
最后,我们将光向量和表面法线做了点乘,并用这个值计算出了最终的漫反射光线颜色。注意,在计算diffuse颜色时,我们使用了if判断句,因为当n_dot_1小于零时说明光线在这个表面的背后,也即这个表面无法接受到光,所以这部分像素点的漫反射光线颜色应该是纯黑色的。当n_dot_1值为0时代表光线与表面法线是完全垂直的,即与表面是完全平行的,那这个面应该不接受漫反射光线。同理,但n_dot_1值为1时代表光线与表面时完全垂直的,能够接受到全部的入射光。最终输出的颜色值是由环境光和漫反射光线的结果结合起来得到的。注意,代码中默认的环境光alpha通道是0,所以假定环境光对这个模型完全没有影响,这样我们才能看到模型的背面是全黑的。
像素着色器中最终输出的像素颜色值由环境光和漫反射光相加得到,像素点的透明度由图片的alpha通道决定。下图为对模型使用了之前使用的地球贴图并将环境光强度调为0后的显示结果:
注意,在这张图中可以看出在模型的下面加入了一个平行光。NVIDIA FX Composer可以选择在Render Panel中显示创建的环境光(ambient)、点光(point)、聚光灯(spot)和平行光(directional)。要增加这些光可以在主工具栏中选择或者是在Create菜单中添加。要使添加的平行光和你的shader联系起来,必须将灯光绑定到LightColor和LightDirection常量中。这里对象中的注解就派上用场了。要绑定灯光需要先选中Render面板中的地球球体,然后选中属性面板中的Material Instance Properties选项,这个图标位于面板上图标的第五个(详见下图)。再为面板中的directionallight0和lightcolor0两个常量选中你刚刚创建的那个平行光。现在,你的平行光已经绑定到这个shader中了,你对平行光所做的操作都会体现在球体的光照上,比如旋转平行光,你会发现模型上的阴影位置改变了。但需要注意,由于平行光没有具体的位置概念,因此不管你怎么改变平行光模型的位置都不会对模型的光线产生任何影响。你也可以通过选中平行光在属性面板中改变平行光的颜色和强度。
警告 |
NVIDIA FX Composer工具支持手动、自动绑定。当启用自动绑定时,工具会尽量帮你在项目中查找最适合的灯光来绑定shader常量。但这不是总能正确成功的,所以你需要自己去检查常量有没有被正确绑定。 |
但是,当你rebuild这个shader时,手动绑定的常量会遗失,需要再重新绑定。 |
这篇文章中主要介绍了一个最简单的漫反射光照模型是如何实现的,关于漫反射光照模型还有其他更为详细的实现方式,比如龙书中的相关章节就给出了更多参数的Lambert光照模型。漫反射光照模型是基础,下一篇文章中的高光模型就需要在漫反射的基础上加入高光点的计算。关于更多的光照模型,可以参阅参考链接【2】中给出的资料。
【1】HLSL中的语义。(http://blog.csdn.net/pizi0475/article/details/6264388)
【2】简单光照模型。(http://www.cnblogs.com/mavaL/archive/2010/11/01/1866451.html)