Normals & Lighting

大家好,我是西瓜,你现在看到的是 2021 iThome 铁人赛『如何在网页中绘制 3D 场景?从 WebGL 的基础开始说起』系列文章的第 17 篇文章。本系列文章从 WebGL 基本运作机制以及使用的原理开始介绍,最後建构出绘制 3D、光影效果之网页。如果在阅读本文时觉得有什麽未知的东西被当成已知的,可能可以在前面的文章中找到相关的内容

在这个章节中将会加入『光』的元素,使得物体在有光照射的时候才会有颜色,并利用上个章节提到的 twgl 让程序码可以写的比较愉快,最後加入反射光的计算,使得渲染的成像更真实一些

04-lighting.html / 04-lighting.js

这个章节使用新的一组 .html / .js 作为开始,完整程序码可以在这边找到:github.com/pastleo/webgl-ironman/commit/2d72d48,这次的起始点跑起来就有一个木质地板跟一颗球:

start-point-screenshot

虽然是 CC0,不过笔者还是标注一下好了,这个场景中使用到的 texture 是在 opengameart.org 找到的:Commission - Medieval, 2048² wooden texture

需要复习『在 WebGL 里头使用图片 (texture) 进行绘制』的话,请参考 Day 6 的内容,在这个起始点也拿 Day 14 实做的相机控制过来,完整的 live 版本在此:

https://static.pastleo.me/webgl-ironman/commits/2d72d48e66e41a968c65b4870c1cfb8391157710/04-lighting.html

目前木质地板与球只是按照原本 texture 上的颜色进行绘制,本篇的目标是加入一个从无限远的地方照射过来的白色平行光 (directional light),并且在物体表面计算『散射 (diffuse)』之後从任意角度观察到的颜色,在维基百科上的这张图表示了散射光的方向:

wiki-diffuse-light

法向量 Normal

因为是白光,所以散射之後的颜色其实就是原本的颜色经过一个明暗度的处理,而明暗度要怎麽计算呢?笔者画了下方的意示图尝试解释,首先,如果是在光照不到的区域,像是红色面,与光平行或是背对着光,那麽就会是全黑;被照射到的区域,如绿色与蓝色面,因为一个单位的光通量在与垂直的面上可以形成较小的区域(在绿色面上的橘色线段较蓝色面短),一个单位的面积获得的光通量就比较高,因此绿色面比蓝色面来的更亮

directional-diffuse

总和以上,入射角越垂直面接近法向量,明暗度越高,不过在 fragment shader 内,把向量算回角度再做比较会太伤本,我们可以取光方向反向的单位向量,再与法向量(也必须是单位向量)做内积,这样一来会得到 -1 ~ +1 之间的值表示明暗度;幸好 twgl.primitives 产生的资料不只有 position, texcoord,还有 normal,也就是法向量,场景中的球以及地板都是使用 TWGL 生成的,这边就先来把 normal 传入 vertex shader 内:

 const vertexShaderSource = `
 attribute vec4 a_position;
 attribute vec2 a_texcoord;
+attribute vec4 a_normal;
 // ...
 void main() {
   // ...
 }
 `;

 const attributes = {
   // ...
+  normal: gl.getAttribLocation(program, 'a_normal'),
 };
async function setup() {
  // ...
  { // both ball and ground
    // ...
    // a_normal
    buffers.normal = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffers.normal);

    gl.enableVertexAttribArray(attributes.normal);
    gl.vertexAttribPointer(
      attributes.normal,
      attribs.normal.numComponents, // size
      gl.FLOAT, // type
      false, // normalize
      0, // stride
      0, // offset
    );

    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array(attribs.normal),
      gl.STATIC_DRAW,
    );
    // ...
  }
  // ...
}

法向量的旋转

计算明暗度会在面投影到萤幕的每个 pixel 上进行,也就是 fragment shader,vertex shader 主要的工作是把 a_normal pass 到 fragment shader,但是有一个问题:物体会旋转,我们让顶点的位置透过 u_matrix 做 transform,假设有一个物体转了 90 度,那麽法向量也应该一起转 90 度才对

但是我们不能直接让 a_normalu_matrix 相乘得到旋转後的结果,不仅因为 u_matrix 可能包含了平移、缩放资讯,还有投影到萤幕上的 transform,因此要多传送一个矩阵,这个矩阵只包含了 worldMatrix (物件本身的 transform 矩阵)的旋转。至於从 worldMatrix 中抽取只包含旋转的矩阵,在下面这两个网页中有一些数学方法导出接下来的公式:

笔者尝试理解并统整成这份笔记,结论是:把 worldMatrix 取反矩阵,再取转置矩阵,就可以得到 transform 法向量用的矩阵 -- 也就是只包含旋转的 worldMatrix

lib/matrix.js 中已经有 matrix4.inverse(),要补的是 matrix4.transpose(),根据其定义,实做并不难:

export const matrix4 = {
  // ...
  transpose: 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],
    ];
  },
  // ...
}

假设待会实做在 vertex shader 内转换 normal 的矩阵叫做 u_normalMatrix,在 setup() 中先取得 uniform 位置:

async function setup() {
  // ...
  const uniforms = {
    // ...
    normalMatrix: gl.getUniformLocation(program, 'u_normalMatrix'),
    // ...
  };
  // ...
}

render() 这边计算物件的 worldMatrix 後,依照上面讲的公式实做计算 u_normalMatrix:

function render(app) {
  // ...
  { // both ball and ground
    // const worldMatrix = matrix4.multiply( ... )
    gl.uniformMatrix4fv(
      uniforms.normalMatrix,
      false,
      matrix4.transpose(matrix4.inverse(worldMatrix)),
    );
  }
  // ...
}

在 vertex shader 内就可以直接相乘,并透过 varying v_normal 传送到 fragment shader:

 attribute vec4 a_position;
 attribute vec2 a_texcoord;
+attribute vec4 a_normal;
  
 uniform mat4 u_matrix;
+uniform mat4 u_normalMatrix;
  
 varying vec2 v_texcoord;
+varying vec3 v_normal;
  
 void main() {
   gl_Position = u_matrix * a_position;
   v_texcoord = vec2(a_texcoord.x, 1.0 - a_texcoord.y);
+  v_normal = (u_normalMatrix * a_normal).xyz;
 }

计算散射亮度 Diffuse

为了方便调整光线方向观察不同方向的成像,笔者加入 uniform u_lightDir,并且一开始让他直直向下(-y 方向) 照射:

async function setup() {
  // ...
  const uniforms = {
    // ...
    lightDir: gl.getUniformLocation(program, 'u_lightDir'),
    // ...
  };
  // ...
  
  return {
    // ...
    state: {
      // ...
      lightDir: [0, -1, 0],
    },
  };
}

因为整个场景的光线方向都是固定的,因此在 render() 物件以外的范围设定 u_lightDir:

function render(app) {
  // ...
  gl.uniform3f(uniforms.lightDir, ...state.lightDir);
  // ...
}

最後终於可以来写 fragment shader 实做计算明暗度计算:

precision highp float;

uniform vec3 u_color;
uniform sampler2D u_texture;
uniform vec3 u_lightDir;

varying vec2 v_texcoord;
varying vec3 v_normal;

void main() {
  vec3 color = u_color + texture2D(u_texture, v_texcoord).rgb;
  vec3 normal = normalize(v_normal);
  vec3 surfaceToLightDir = normalize(-u_lightDir);
  float colorLight = clamp(dot(surfaceToLightDir, normal), 0.0, 1.0);
  gl_FragColor = vec4(color * colorLight, 1);
}

main() 的第 2 行使用 glsl 内建的 normalize function 计算 v_normal 的单位向量,因为从 vertex shader 过来的 varying 经过『补间』处理可能导致不是单位向量,第 13 行计算『表面到光源』的方向,同样使之为单位向量

main() 的第 4 行大概就是本篇最关键的一行,如同上方讲的使用 glsl 的内积 function 计算明暗度:dot(surfaceToLightDir, normal) ,不过为了避免数值跑到负的,再套上 glsl 的 clamp 把范围限制在 0~1 之间,最後乘上原本的颜色 color,把明暗度套用上去,存档重整後:

directional-light-applied

笔者稍微把视角往右上角调整了一下,可以看到球体的因为向着正下方的光线,只有上方比较亮,而地板因为原本就是向上的,所以就没有变化

到了这边其实应该把 color 改名成 diffuse,因为一个物体其实可以分成不同种类的光对其表面产生的颜色,今天实做的是散射光,之後还会有反射光、自发光等;同时笔者也加上光线方向的使用者控制,完整程序码可以在这边找到:

光源方向调整起来像是这样:

adjusting-light-dir


<<:  Day.9 备份还原 - 还原资料 (MYSQL binlog )-下

>>:  加上random与time模组,限制次数与时间的管理(3)

【D9】厨具熟练後制作精致:使用历史资料的Kbar做MA(移动平均线)图

前言 当熟悉了历史资料,发现有更厉害的K线,这时候就要善用工具,好好的料理资料一番,这次我们来用K线...

简单的先做 VS 技术难题先做

这个问题来自於我的专案执行的经验,也让大家一起想想看。 在收到专案後,第一个动作就是去寻找这个专案的...

[Day3]PHP的资料型态03

PHP的资料型态 Object 对象 要创建一个新的对象 object,使用 new 语句实例化一个...

Day 21:在 Hexo 增加作者版权声明(使用 Next 布景)

内容发布到网路上,由於都是开放的,不管是你写的文章、拍摄的相片或是影片,有一定的机率会被转贴。有些人...

[Day25] 第二十五章-新增空白的point表单 (跨资料查询还有对应细节)

前言 昨天我们完成了point简单的read 跟route model controll等 今天我们...