文档章节

翻译:非常详细易懂的法线贴图(Normal Mapping)

FreeBlues
 FreeBlues
发布于 2016/08/05 22:55
字数 3607
阅读 510
收藏 1

翻译:非常详细易懂的法线贴图(Normal Mapping)

这一系列依赖于最小规模的用于着色器和渲染工具的lwjgl-basics API. 代码已经被移植到 LibGDX. 这些概念是足够通用的, 它们能被应用于Love2D, GLSL Sandbox, iOS, 或者其他支持 GLSL 的平台.

概述

本文聚焦于 3D 光照和法线贴图技术, 以及我们如何把它们应用到 2D 游戏中, 示范下图所示, 左边是纹理贴图, 右边实时应用了光照:

一旦你理解了光照的概念, 把它应用于任何设置都是非常直截了当的. 这里是一个 Java4K 示例中的法线贴图的例子, 例如, 通过软件渲染:

效果跟这个 YouTube流行视频 和这个 Love2D示例 中展示的一样, 你还可以在 [GLSL] Using Normal Maps to Illuminate a 2D Texture (LibGDX) 看到效果, 其中包括一个可执行的示例.

介绍向量和法线

正如我们在之前的教程中讨论过的, 一个 GLSL 向量是一个浮点数的容器, 通常保存诸如位置(x,y,z)之类的值. 在数学中,向量意味着相当多的内容,以及用于表示长度(即大小)和方向. 如果你对向量很陌生并且想要学习关于它们更多一些的知识, 查看下面这些链接:

为了计算光照, 我们需要使用网格的"法线". 一个表面法线是一个垂直于切线平面的向量. 简单来说, 它是一个向量, 垂直于给定顶点处的网格. 下面我们会看到一个网格, 每个顶点都有一条法线.

每个向量都指向外面, 遵循着网格的弯曲形状. 下面是另一个例子, 这次是一个简单的 2D 边沿视图:

法线贴图(Normal Mapping)是一个游戏编程技巧, 它允许我们渲染相同数目的多边形(例如低解析度的网格模型), 但是在计算光照时使用高解析度网格模型的法线. 这为我们带来更好的感受, 关于深度, 真实性和光滑度.

(图像来自于这个出色的博客文章Making Worlds 3 - That's no Moon...)

高面数网格模型或者说精雕模型的法线被编码到一个纹理贴图(即法线图)中, 当我们渲染低面数网格模型时会从片段着色器中对它进行取样. 结果如下:

译者注: 左侧是4百万个三角形的高模, 中间是500个三角形的低模, 右侧是在500个三角形的低模上使用法线贴图后的效果

对法线编码和解码

我们的表面法线是单位向量, 通常位于范围 -1.01.0 之间. 我们可以通过把法线范围转换为 0.01.0之间来把法线向量(x, y, z)存储到一个 RGB 纹理贴图中. 下面是伪码:

Color.rgb = Normal.xyz / 2.0 + 0.5;

例如, 一个法线 (-1, 0, 1) 会被作为 RGB 编码为 (0, 0.5, 1). x 轴(左/右)被保存到红色通道, y 轴(上/下)被保存到绿色通道, z 轴(前/后)被保存到蓝色通道. 最终的法线图(normal map)看起来就是下面这个样子:

典型地, 我们使用程序来生成法线图, 而不是手动绘制.

理解法线图, 把每个通道独立出来查看会更清楚:

看着,绿色通道,我们看到更亮的部分(值更接近于 1.0) 定义了法线指向上方的区域,而更暗的区域(值更接近为 0.0) 定义了法线指向下方的区域. 大多数的法线图会是蓝色,因为Z轴(蓝色通道)通常指向我们(即值为 1.0).

在我们游戏的片段着色器中, 我们可以把法线解码, 通过执行跟之前编码时相反的操作, 把颜色值展开为范围 -1.01.0 之间:

//sample the normal map
NormalMap = texture2D(NormalMapTex, TexCoord);

//convert to range -1.0 to 1.0
Normal.xyz = NormalMap.rgb * 2.0 - 1.0;

注意: 要记住不同的引擎和软件会使用不同的坐标系, 绿色通道可能需要翻转.

Lambertian 光照模型

在计算机图形学中, 我们有大量的算法,可以结合起来打造 3D 对象的不同渲染效果. 在这篇文章我们将专注于 Lambert 着色,没有任何反射(诸如"光泽"或"发光"). 其他的技术,像Phong, Cook-Torrance, 和 Oren–Nayar, 可以用来产生不同的视觉效果(粗糙表面、 有光泽的表面等等)。

我们整个光照模型看起来像这样:

N = normalize(Normal.xyz)
L = normalize(LightDir.xyz)

Diffuse = LightColor * max(dot(N, L), 0.0)

Ambient = AmbientColor * AmbientIntensity

Attenuation = 1.0 / (ConstantAtt + (LinearAtt * Distance) + (QuadraticAtt * Distance * Distance)) 

Intensity = Ambient + Diffuse * Attenuation

FinalColor = DiffuseColor.rgb * Intensity.rgb

说实话,你不需要从数学角度理解为什么这个可以起作用,但如果你有兴趣, 可以阅读更多有关"N dot L"的内容, 在这里GLSL Tutorial – Directional Lights per Vertex I和这里Lambertian reflectance.

一些关键的术语:

  • Normal-法线: 从法线图中解码得到的法线向量 XYZ.
  • LightDir-光线方向: 从物体表面到光源位置的向量, 我们将会简单解释.
  • Diffuse Color-漫射颜色: 纹理贴图的 RGB 颜色, 没有光.
  • Diffuse-漫射: 跟Lambertian反射相乘的光线颜色, 这是我们光照等式的主要部分.
  • Ambient-环境光: 处于阴影中的颜色和强度, 例如, 一个户外场景会有一个更亮的环境光强度, 比起一个暗淡灯光下的户内场景.
  • Attenuation-衰减: 这是光线的随距离而降低, 例如, 当我们远离点光源时强度/亮度的损失. 有多种方法来计算衰减--对于我们的目标而言, 我们将会使用常量-线性-二次方衰减. 这里用3个系数来计算衰减, 我们可以改变它们来影响光线衰减的视觉效果.
  • Intensity-强度: 我们阴影算法的强度--离1.0越近意味着有光, 离0.0越近意味着没有光.

下面的图有助于你对我们的光照模型有个直观的理解:

正如你所见, 感觉它是相当模块化的, 我们可以拿走那些不需要的部分, 就像衰减(attenuation) 或光线颜色(light colors).

现在, 让我们把它们应用到 GLSL 模型上. 注意我们只处理 2D, 在 3D 中还有一些额外的考虑在这篇教程没有覆盖到(译者注:就是空间变换, 在 3D 场景下, 法线图中的法线所在的空间为正切空间, 光线所在的空间为世界空间, 需要统一到同一个空间计算才有意义). 我们将把模型分解为多个单独部分, 每一个都建立在下面的基础上.

Java 例程

你可以在这里看到Java代码示例. 它是相对直截了当的, 并不会介绍过多的在在前面的课程中还没有讨论过的内容. 我们将使用以下两种纹理贴图︰

我们的示例根据鼠标位置(归一化到分辨率)调整 LightPos.xy, 根据鼠标滚轮(点击则重置光线的 Z值)调整 LightPos.z(深度). 在特定的坐标系中, 就像 LibGDX, 你可能需要翻转 Y 值.

注意, 我们的例子使用了如下这些常量, 你可以调整它们来获得不同的视觉效果:

public static final float DEFAULT_LIGHT_Z = 0.075f;
...
//Light RGB and intensity (alpha)
public static final Vector4f LIGHT_COLOR = new Vector4f(1f, 0.8f, 0.6f, 1f);

//Ambient RGB and intensity (alpha)
public static final Vector4f AMBIENT_COLOR = new Vector4f(0.6f, 0.6f, 1f, 0.2f);

//Attenuation coefficients for light falloff
public static final Vector3f FALLOFF = new Vector3f(.4f, 3f, 20f);

下面是我们的渲染代码, 就像 教程4 一样, 我们会在渲染时使用多重纹理:

...

//update light position, normalized to screen resolution
float x = Mouse.getX() / (float)Display.getWidth();
float y = Mouse.getY() / (float)Display.getHeight();
LIGHT_POS.x = x;
LIGHT_POS.y = y;

//send a Vector4f to GLSL
shader.setUniformf("LightPos", LIGHT_POS);

//bind normal map to texture unit 1
glActiveTexture(GL_TEXTURE1);
rockNormals.bind();

//bind diffuse color to texture unit 0
glActiveTexture(GL_TEXTURE0);
rock.bind();

//draw the texture unit 0 with our shader effect applied
batch.draw(rock, 50, 50);

阴影贴图的结果:

下面对光线使用了更低的 Z 值:

片段着色器

这里是我们完整的片段着色器

//attributes from vertex shader
varying vec4 vColor;
varying vec2 vTexCoord;

//our texture samplers
uniform sampler2D u_texture;   //diffuse map
uniform sampler2D u_normals;   //normal map

//values used for shading algorithm...
uniform vec2 Resolution;      //resolution of screen
uniform vec3 LightPos;        //light position, normalized
uniform vec4 LightColor;      //light RGBA -- alpha is intensity
uniform vec4 AmbientColor;    //ambient RGBA -- alpha is intensity 
uniform vec3 Falloff;         //attenuation coefficients

void main() {
    //RGBA of our diffuse color
    vec4 DiffuseColor = texture2D(u_texture, vTexCoord);

    //RGB of our normal map
    vec3 NormalMap = texture2D(u_normals, vTexCoord).rgb;

    //The delta position of light
    vec3 LightDir = vec3(LightPos.xy - (gl_FragCoord.xy / Resolution.xy), LightPos.z);

    //Correct for aspect ratio
    LightDir.x *= Resolution.x / Resolution.y;

    //Determine distance (used for attenuation) BEFORE we normalize our LightDir
    float D = length(LightDir);

    //normalize our vectors
    vec3 N = normalize(NormalMap * 2.0 - 1.0);
    vec3 L = normalize(LightDir);

    //Pre-multiply light color with intensity
    //Then perform "N dot L" to determine our diffuse term
    vec3 Diffuse = (LightColor.rgb * LightColor.a) * max(dot(N, L), 0.0);

    //pre-multiply ambient color with intensity
    vec3 Ambient = AmbientColor.rgb * AmbientColor.a;

    //calculate attenuation
    float Attenuation = 1.0 / ( Falloff.x + (Falloff.y*D) + (Falloff.z*D*D) );

    //the calculation which brings it all together
    vec3 Intensity = Ambient + Diffuse * Attenuation;
    vec3 FinalColor = DiffuseColor.rgb * Intensity;
    gl_FragColor = vColor * vec4(FinalColor, DiffuseColor.a);
}

GLSL 分解

现在, 把它分解. 首先, 我们从两个纹理贴图中取样:

//RGBA of our diffuse color
vec4 DiffuseColor = texture2D(u_texture, vTexCoord);

//RGB of our normal map
vec3 NormalMap = texture2D(u_normals, vTexCoord).rgb;

接着, 我们需要从当前的片段(译者注:即像素)确定光线向量, 并且纠正它的纵横比例(aspect ratio). 然后在归一化(normalize)之前确定 LightDir 向量的值(长度):

//Delta pos
vec3 LightDir = vec3(LightPos.xy - (gl_FragCoord.xy / Resolution.xy), LightPos.z);

//Correct for aspect ratio
LightDir.x *= Resolution.x / Resolution.y;

//determine magnitude
float D = length(LightDir);

在我们的光照模型中, 我们需要从 NormalMap.rgb 中解码 Normal.xyz, 并且归一化我们的向量:

vec3 N = normalize(NormalMap * 2.0 - 1.0);
vec3 L = normalize(LightDir);

下一步是计算 Diffuse(漫射) 项. 为了这个, 我们需要使用 LightColor. 在我们的例子中, 我们将会把光线颜色(RGB)和强度(alpha)相乘: LightColor.rgb * LightColor.a. 因此, 所有这些看起来如下:

//Pre-multiply light color with intensity
//Then perform "N dot L" to determine our diffuse term
vec3 Diffuse = (LightColor.rgb * LightColor.a) * max(dot(N, L), 0.0);

接着, 我们预相乘(pre-multiply)环境颜色(ambient color)和强度:

vec3 Ambient = AmbientColor.rgb * AmbientColor.a;

下一步是用我们的 LightDir的值(前面计算好的)来确定衰减(Attenuation). 统一变量下降系数(Falloff) 定义了我们的常量, 线性和2次方的衰减系数:

float Attenuation = 1.0 / ( Falloff.x + (Falloff.y*D) + (Falloff.z*D*D) );

接着, 计算光强度(Intensity)和最终颜色(FinalColor), 并且把它们传递给 gl_FragColor. 注意, 我们机智地保留了 DiffuseColoralpha 值:

vec3 Intensity = Ambient + Diffuse * Attenuation;
vec3 FinalColor = DiffuseColor.rgb * Intensity;
gl_FragColor = vColor * vec4(FinalColor, DiffuseColor.a);

抓住你了(Gotchas)

  • 在我们的实现中, LightDirattenuation 依赖于分辨率. 这意味着更改分辨率会影响我们的光的衰减. 根据你的游戏,不同的实现上分辨率无关可能是必需的.
  • 一个必须处理的常见问题, 关于你游戏的 Y 坐标系和你所采用的法线图生成程序(例如 CrazyBump)之间的差异. 一些程序允许你导出一个翻转了Y轴的法线图. 下面的图片展示了这个问题:

多光源

实现多光源, 我们只要简单地调整一下算法, 如下:

vec3 Sum = vec3(0.0);
for (... each light ...) {
    ... calculate light using our illumination model ...
    Sum += FinalColor;
}
gl_FragColor = vec4(Sum, DiffuseColor.a);

注意, 这样会在你的着色器中引入更多分支(译者注:也就是这个循环), 它会导致性能降低.

这有时被称为"N 照明"(N lighting), 因为我们的系统仅支持一个固定数目 N 的光源. 如果你计划包括大量的光源, 你可能想要调查多个绘制调用(例如 additive blending), 或延迟渲染Deferred shading.

在某个时间点, 你可能会问自己:"为什么我不直接做一个3D游戏?". 比起试着把这些概念应用到 2D 精灵来说, 这是个正当的问题并且可能会带来更好的性能和更少的开发时间.

生成法线图

这里有各种从一张图片生成法线图的方法. 用于转换2D图像为法线图的常用程序和滤镜包括如下:

注意, 很多程序都会产生锯齿和错误, 阅读这篇文章How NOT To Make Normal Maps From Photos Or Images来获得更多细节.

你也能使用 3D 建模软件, 如 BlenderZBrush 来精心雕琢出高质量的法线图.

Blender工具

一个工作流的想法是, 生成一个低面数,非常粗糙的 3D 对象在你的艺术资源中. 然后你可以使用这个 Blender Template: Normal Map Pass 把你的对象渲染为一个 2D 正切空间内的法线图. 然后你就能在 PhotoShop 中打开这个法线图并且处理这个漫射(diffuse)颜色图了.

下面是一个 Blender 模板的样子:

进阶阅读

附录:像素艺术

在创建我的 WebGL法线图像素艺术演示时, 有一堆我不得不考虑的事项. 你可以从这里查看源码和细节.

效果如下图: 输入图片说明

在这个示例中, 我想让衰减作为一个风格元素变得可见. 典型的做法带来非常平滑的衰减, 它和块状像素艺术风格冲突. 相反, 我使用 cel shading 的光线, 给它一个阶梯状的衰减. 通过片段着色器中的 if-else 语句实现了简单的卡通着色.

下一步的考虑是, 我们希望光线的边缘像素的比例随着精灵(sprites)的像素变化. 实现这个目标的一个方法是通过光照着色器把我们的场景绘制到一个 FBO 中, 然后用一个默认的着色器以一个较大的尺寸把它渲染到屏幕上. 在我们的块状像素艺术中这种照明方式影响整个"纹素"(texels).

其他 APIs

© 著作权归作者所有

共有 人打赏支持
FreeBlues
粉丝 98
博文 280
码字总数 493678
作品 0
其它
程序员
私信 提问
【Unity3D技术文档翻译】第2.3.3.6篇 法线贴图(凹凸贴图)

上一章:【Unity3D技术文档翻译】第2.3.3.5篇 平滑度(Smoothness) 本章原文所在章节:【Unity Manual】→【Graphics】→【Graphics Overview】→【Materials, Shaders & Textures】→【Sta...

何三思
2018/06/24
0
0
[OpenGL] Normal Mapping 法线映射 - 附我的实现

最近准备填一下坑儿,整理一下之前写过的一些shader程序。程序都是在Qt下进行OpenGL ES的相关开发,不过对于Shader代码,不管是何处使用都没有什么太大差异。 填坑篇(一): Normal Mapping...

Mahabharata_
2017/08/13
0
0
【Unity3D技术文档翻译】第2.5篇 通过脚本访问和修改材质参数

上一章:【Unity3D技术文档翻译】第2.4篇 PBR材质验证器(Physically Based Rendering Material Validator) 本章原文所在章节:【Unity Manual】→【Graphics】→【Graphics Overview】→【...

何三思
2018/06/28
0
0
凹凸贴图(Bump Mapping)

凹凸贴图(Bump Mapping) 目录 概述 * 说明, 下文由网络搜集, 找不到原来的翻译者, 只有一些转载链接附在参考里 凹凸映射和纹理映射非常相似。然而,纹理映射是把颜色加到多边形上,而凹凸映射...

FreeBlues
2016/04/17
41
0
翻译:GLSL的顶点位移贴图

翻译:GLSL的顶点位移贴图 翻译自: Vertex Displacement Mapping using GLSL - 译者: FreeBlues 说明: 之所以选择这篇文档, 是因为现在但凡提到位移贴图(Displacement Mapping), 都要求设备支...

FreeBlues
2016/07/23
79
2

没有更多内容

加载失败,请刷新页面

加载更多

[walminer bug分析]checkpoint wal记录的lsn与checkpoint记录的redo点的关系

问题背景 walminer工具的用户反馈来一个问题,不管添加了多少wal日志,想要的wal文件的解析结果总是有未解析出的部分。 分析问题 分析问题发现,checkpoint wal记录之后对某个数据page进行修...

movead
57分钟前
4
0
OSChina 周二乱弹 —— 金 冈刂 犭良

Osc乱弹歌单(2019)请戳(这里) 【今日歌曲】 @蓝瞳 :分享骇物乐团的单曲《I'll be the one》: 《I'll be the one》- 骇物乐团 手机党少年们想听歌,请使劲儿戳(这里) @尾生 :工作使人...

小小编辑
今天
514
11
python中类方法和静态方法区别

面相对象程序设计中,类方法和静态方法是经常用到的两个术语。 逻辑上讲:类方法是只能由类名调用;静态方法可以由类名或对象名进行调用。 在C++中,静态方法与类方法逻辑上是等价的,只有一...

xiangyunyan
今天
14
0
Hibernate SQLite方言

以下代码有参考过github上国外某位大佬的,在发文的最新稳定版Hibernate上是可用的,有时间再仔细分析一下 import org.hibernate.dialect.Dialect;import org.hibernate.dialect.function.S...

CHONGCHEN
今天
4
0
CentOS 7 MariaDB搭建主从服务器

本文编写环境为CentOS7。确保关闭SELinux,关闭防火墙或者防打开指定端口。具体信息如下 #master[root@promote ~]# cat /etc/redhat-release CentOS Linux release 7.6.1810 (Core) [r...

白豆腐徐长卿
今天
14
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部