目录

WebGL2Fundamentals.org

Fix, Fork, Contribute

WebGL2 三维方向光源

此文上接WebGL 三维相机, 如果没读建议从那里开始

实施光照的方式有很多种,最简单的可能就是方向光源了。

方向光是指光照均匀地来自某一个方向,晴朗天气下的太阳经常被当作方向光源, 它距离太远所以光线被看作是平行的照到地面上。

计算方向光非常简单,将方向光的方向和面的朝向点乘就可以得到两个方向的余弦值。

这有个例子

drag the points

随意拖动其中的点,如果两点方向刚好相反,点乘结果则为 -1。 如果方向相同结果为 1。

这有什么用呢?如果将三维物体的朝向和光的方向点乘, 结果为 1 则物体朝向和光照方向相同,为 -1 则物体朝向和光照方向相反。

rotate the direction

我们可以将颜色值和点乘结果相乘,BOOM!有光了!

还有一个问题,我们如何知道三维物体的朝向?

法向量

我不知道为什么叫法向量,但是在三维图形学中法向量就是描述面的朝向的单位向量。

这是正方体和球体的一些法向量。

这些插在物体上的线就是对应顶点的法向量。

注意到正方体在每个顶角有 3 个法向量。 这是因为需要 3 个法向量去描述相邻的每个面的朝向。

这里的法向量是基于他们的方向着色的,正 x 方向为 红色, 上方向为 绿色 ,正 z 方向为 蓝色.

让我们来给上节中的 F 添加法向量。 由于 F 非常规则并且朝向都是 x, y, z 轴,所以非常简单。正面的的部分法向量为 0, 0, 1, 背面的部分法向量为 0, 0, -1,左面为 -1, 0, 0,右面为 1, 0, 0,上面为 0, 1, 0, 然后底面为 0, -1, 0

function setNormals(gl) {
  var normals = new Float32Array([
          // 正面左竖
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,

          // 正面上横
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,

          // 正面中横
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,

          // 背面左竖
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,

          // 背面上横
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,

          // 背面中横
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,

          // 顶部
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,

          // 上横右面
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,

          // 上横下面
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,

          // 上横和中横之间
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,

          // 中横上面
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,

          // 中横右面
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,

          // 中横底面
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,

          // 底部右侧
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,

          // 底面
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,

          // 左面
          -1, 0, 0,
          -1, 0, 0,
          -1, 0, 0,
          -1, 0, 0,
          -1, 0, 0,
          -1, 0, 0,
  ]);
  gl.bufferData(gl.ARRAY_BUFFER, normals, gl.STATIC_DRAW);
}

在代码中使用它们,先移除顶点颜色以便观察光照效果。

// 找顶点着色器中的属性
var positionLocation = gl.getAttribLocation(program, "a_position");
-var colorLocation = gl.getAttribLocation(program, "a_color");
+var normalLocation = gl.getAttribLocation(program, "a_normal");

...

-// 创建一个缓冲存储颜色
-var buffer = gl.createBuffer();
-gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
-gl.enableVertexAttribArray(colorLocation);
-
-// 我们将提供 RGB 作为字节
-gl.vertexAttribPointer(colorLocation, 3, gl.UNSIGNED_BYTE, true, 0, 0);
-
-// 设置颜色
-setColors(gl);

// 创建缓冲存储法向量
var buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(normalLocation);
gl.vertexAttribPointer(normalLocation, 3, gl.FLOAT, false, 0, 0);

// 设置法向量
setNormals(gl);

现在让着色器使用它

首先在顶点着色器中只将法向量传递给片断着色器

#version 300 es

// 属性是顶点着色器的输入(in),它将从缓冲区接收数据
in vec4 a_position;
-in vec4 a_color;
+in vec3 a_normal;

// 用于转换位置的矩阵
uniform mat4 u_matrix;

-// 定义颜色变量传递给片段着色器
-out vec4 v_color;

+// 定义法向量变量传递给片段着色器
+out vec3 v_normal;

// 所有着色器都有一个main函数
void main() {
  // 将位置乘以矩阵
  gl_Position = u_matrix * a_position;

-  // 将颜色变量传递给片段着色器
-  v_color = a_color;

+  // 将法向量传递给片段着色器
+  v_normal = a_normal;
}

然后在片断着色器中将法向量和光照方向点乘

#version 300 es

precision highp float;

-// 从顶点着色器中传入的颜色值
-in vec4 v_color;

+// 从顶点着色器中传入的法向量值
+in vec3 v_normal;
+
+uniform vec3 u_reverseLightDirection;
+uniform vec4 u_color;

// 我们需要为片段着色器声明一个输出
out vec4 outColor;

void main() {
-  outColor = v_color;
+  // 因为 v_normal 是一个变化的插值所以它不会是一个单位向量。 归一化使它成为单位向量
+  vec3 normal = normalize(v_normal);
+
+  // 通过取法线与光线反向的点积计算光
+  float light = dot(normal, u_reverseLightDirection);
+
+  outColor = u_color;
+
+  // 让我们只将颜色部分(不是 alpha)乘以光
+  outColor.rgb *= light;
}

然后找到 u_coloru_reverseLightDirection 的位置。

  // 寻找全局变量
  var matrixLocation = gl.getUniformLocation(program, "u_matrix");
+  var colorLocation = gl.getUniformLocation(program, "u_color");
+  var reverseLightDirectionLocation =
+      gl.getUniformLocation(program, "u_reverseLightDirection");

为它们赋值

  // 设置矩阵
  gl.uniformMatrix4fv(matrixLocation, false, matrix);

+  // 设置使用的颜色
+  gl.uniform4fv(colorLocation, [0.2, 1, 0.2, 1]); // green
+
+  // 设置光线方向
+  gl.uniform3fv(reverseLightDirectionLocation, normalize([0.5, 0.7, 1]));

我们之前用到的 normalize 会将原向量转换为单位向量。 例子中的 x = 0.5 说明光线是从右往左照,y = 0.7 说明光线从上方往下照, z = 1 说明光线从在场景前方。对应的值表示光源大多指向场景,在靠右方和上方一点的位置。

这是结果

如果你旋转了 F 就会发现,F 虽然旋转了但是光照没变, 我们希望随着 F 的旋转正面总是被照亮的。

为了解决这个问题就需要在物体重定向时重定向法向量, 和位置一样我们也可以将向量和矩阵相乘,这个矩阵显然是 world 矩阵, 现在我们只传了一个矩阵 u_matrix,所以先来改成传递两个矩阵, 一个叫做 u_world 的世界矩阵,另一个叫做 u_worldViewProjection 也就是我们现在的 u_matrix

#version 300 es

// 属性是顶点着色器的输入(in),它将从缓冲区接收数据
in vec4 a_position;
in vec3 a_normal;

*uniform mat4 u_worldViewProjection;
+uniform mat4 u_world;

varying vec3 v_normal;

void main() {
  // 将位置和矩阵相乘
*  gl_Position = u_worldViewProjection * a_position;

*  // 重定向法向量并传递给片断着色器
*  v_normal = mat3(u_world) * a_normal;
}

注意到我们将 a_normalmat3(u_world) 相乘, 那是因为法向量是方向所以不用关心位移, 矩阵的左上 3x3 部分才是控制姿态的。

找到全局变量

  // 寻找全局变量
-  var matrixLocation = gl.getUniformLocation(program, "u_matrix");
*  var worldViewProjectionLocation =
*      gl.getUniformLocation(program, "u_worldViewProjection");
+  var worldLocation = gl.getUniformLocation(program, "u_world");

然后更新它们

*var worldMatrix = m4.yRotation(fRotationRadians);
*var worldViewProjectionMatrix = m4.multiply(viewProjectionMatrix,
                                             worldMatrix);

*// 设置矩阵
*gl.uniformMatrix4fv(
*    worldViewProjectionLocation, false,
*    worldViewProjectionMatrix);
*gl.uniformMatrix4fv(worldLocation, false, worldMatrix);

结果

旋转后就会发现面对 F 的面总是被照亮的。

这里有一个问题我不知道如何表述所以就用图解展示。 我们用 normalu_world 相乘去重定向法向量, 如果世界矩阵被缩放了怎么办?事实是会得到错误的法向量。

click to toggle normals

我从没想弄清为什么,但解决办法就是对世界矩阵求逆并转置, 用这个矩阵就会得到正确的结果。

在图解里中间的 紫色球体是未缩放的, 左边的红色球体用的世界矩阵并缩放了, 你可以看出有些不太对劲。右边蓝色 的球体用的是世界矩阵求逆并转置后的矩阵。

点击图解循环观察不同的表示形式,你会发现在缩放严重时左边的(世界矩阵) 法向量和表面没有保持垂直关系,而右边的(世界矩阵求逆并转置) 一直保持垂直。最后一种模式是将它们渲染成红色,你会发现两个球体的光照结果相差非常大, 基于可视化的结果可以得出使用世界矩阵求逆转置是对的。

修改代码让示例使用这种矩阵,首先更新着色器,理论上我们可以直接更新 u_world 的值,但是最好将它重命名以表示它真正的含义,防止混淆。

#version 300 es

// 属性是顶点着色器的输入(in),它将从缓冲区接收数据
in vec4 a_position;
in vec3 a_normal;

uniform mat4 u_worldViewProjection;
-uniform mat4 u_world
+uniform mat4 u_worldInverseTranspose;

// 定义传递到片段着色器的颜色变量和法向量
out vec4 v_color;
out vec3 v_normal;

// 所有的着色器都有一个 main 函数
void main() {
  // 将位置和矩阵相乘
  gl_Position = u_worldViewProjection * a_position;

  // 重定向法向量并传递给片断着色器
*  v_normal = mat3(u_worldInverseTranspose) * a_normal;
}

然后找到它

-  var worldLocation = gl.getUniformLocation(program, "u_world");
+  var worldInverseTransposeLocation =
+      gl.getUniformLocation(program, "u_worldInverseTranspose");

更新它

var worldMatrix = m4.yRotation(fRotationRadians);
var worldViewProjectionMatrix = m4.multiply(viewProjectionMatrix, worldMatrix);
+var worldInverseMatrix = m4.inverse(worldMatrix);
+var worldInverseTransposeMatrix = m4.transpose(worldInverseMatrix);

// 设置矩阵
gl.uniformMatrix4fv(
    worldViewProjectionLocation, false,
    worldViewProjectionMatrix);
-gl.uniformMatrix4fv(worldLocation, false, worldMatrix);
+gl.uniformMatrix4fv(
+    worldInverseTransposeLocation, false,
+    worldInverseTransposeMatrix);

这是转置的代码

var m4 = {
  transpose: function(m) {
    return [
      m[0], m[4], m[8], m[12],
      m[1], m[5], m[9], m[13],
      m[2], m[6], m[10], m[14],
      m[3], m[7], m[11], m[15],
    ];
  },
  ...

由于我们并没有进行缩放,所以没有明显的变化,但防患于未然。

希望这光照的第一课解释的足够清楚,接下来是点光源

mat3(u_worldInverseTranspose) * a_normal 的可选方案

之前的着色器中出现了这样的代码

v_normal = mat3(u_worldInverseTranspose) * a_normal;

我们也可以这样做

v_normal = (u_worldInverseTranspose * vec4(a_normal, 0)).xyz;

由于 w 在相乘前被赋值为 0,所以在相乘后负责平移的部分与 0 相乘被移除了。 我认为这可能是更常用的方式,mat3 的做法只是对我来说更简洁但我经常用前一种方法。

当然另一种解决方案是将u_worldInverseTranspose直接构造成mat3。 这有两个不能这么做的理由,第一个是我们可能需要一个完整的 u_worldInverseTranspose 对象传递一个mat4给还要给其他地方使用,另一个是我们在JavaScript中的所有矩阵方法都是针对 4x4 的矩阵,如果没有其他特别的需求,没有必要构造一个3x3的矩阵,或者是把 4x4 转换成 3x3 。

有意见或建议? 在GitHub上提issue.
comments powered by Disqus