反射光

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

有了散射光的计算,物体的表面根据有没有被光照射到而显示;而本篇将介绍计算上较为复杂的 specular 反射光,笔者觉得加上这个效果之後,物体就可以呈现金属、或是光滑表面的质感,开始跳脱死板的颜色,接下来以此画面为目标:

specular-target

反射光的计算方法与所需要的资料

在反射光的 wiki 中,反射光的意示图如下:

specualr-reflection

入射角与反射角的角度相同,也就是 θiθr 相同,在本篇实做目标撷图中,其中球体上的白色反光区域,就是光线入射角与反射角角度很接近的地方;而在 fragment shader 内,与计算散射时一样,与其计算角度,不如利用单位向量的内积,先计算光线方向反向 surfaceToLightDirection 与表面到相机方向 surfaceToViewerDirection 的『中间向量』,也就是 surfaceToLightDirectionsurfaceToViewerDirection 两个向量箭头顶点的中间位置延伸而成的单位向量 halfVector,再拿 halfVector 与法向量做内积得到反射光的明暗度:

为了知道表面 O 点到相机方向,我们要在 shader 内计算出表面的位置,也就是只有经过 worldMatrix 做 transform 的位置,因此除了同时包含 worldMatrixviewMatrixu_matrix 之外,也得传 worldMatrix,我们就叫这个 uniform u_worldMatrix;另外也需要传送相机的位置进去 u_worldViewerPosition:

 function render(app) {
   // ...

   twgl.setUniforms(programInfo, {
+    u_worldViewerPosition: state.cameraPosition,
     u_lightDir: state.lightDir,
   });

   // ...

   { // both ball and ground
     // ...
     const worldMatrix = matrix4.multiply(/* ... */);

     twgl.setUniforms(programInfo, {
       u_matrix: matrix4.multiply(viewMatrix, worldMatrix),
+      u_worldMatrix: worldMatrix,
       u_normalMatrix: matrix4.transpose(matrix4.inverse(worldMatrix)),
       u_diffuse: [0, 0, 0],
       u_texture: textures.steel,
     });
     // ...
   }
 }

Vertex Shader: 计算表面到相机方向

应该不难想像,面上某个点的『表面到相机方向』是三角形顶点到相机方向的中间值,符合 Day 5 的 varying 特性,也就是说我们可以让这个方向由顶点计算出来,用 varying 传送给 fragment shader,fragment shader 收到的表面到相机方向就会是平滑补间後的结果,笔者把这个方向叫做 v_surfaceToViewer。有了 u_worldMatrix 以及 u_worldViewerPosition,计算出 v_surfaceToViewer:

 attribute vec4 a_position;
 attribute vec2 a_texcoord;
 attribute vec4 a_normal;
  
 uniform mat4 u_matrix;
+uniform mat4 u_worldMatrix;
 uniform mat4 u_normalMatrix;
+uniform vec3 u_worldViewerPosition;
  
 varying vec2 v_texcoord;
 varying vec3 v_normal;
+varying vec3 v_surfaceToViewer;
  
 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;
+  vec3 worldPosition = (u_worldMatrix * a_position).xyz;
+  v_surfaceToViewer = u_worldViewerPosition - worldPosition;
 }

Fragment Shader: 实做反射光计算

照着上方所说得来实做,并让结果 specular 直接加在颜色的所有 channel 上:

 precision highp float;

 uniform vec3 u_diffuse;
 uniform sampler2D u_texture;
 uniform vec3 u_lightDir;

 varying vec2 v_texcoord;
 varying vec3 v_normal;
+varying vec3 v_surfaceToViewer;
  
 void main() {
   vec3 diffuse = u_diffuse + texture2D(u_texture, v_texcoord).rgb; 
   vec3 normal = normalize(v_normal);
   vec3 surfaceToLightDir = normalize(-u_lightDir);
   float diffuseLight = clamp(dot(surfaceToLightDir, normal), 0.0, 1.0);
-  gl_FragColor = vec4(diffuse * diffuseLight, 1);
+
+  vec3 surfaceToViewerDirection = normalize(v_surfaceToViewer);
+  vec3 halfVector = normalize(surfaceToLightDir + surfaceToViewerDirection);
+  float specularBrightness = dot(halfVector, normal);
+
+  gl_FragColor = vec4(
+    diffuse * diffuseLight + specularBrightness,
+    1
+  );
 }

先是把 v_surfaceToViewer 转成单位向量,而 surfaceToLightDir 在先前已经有算出来为单位向量,两者长度皆为 1,加在一起除以二可以得到『中间向量』,但是之後也得转换成为单位向量,除以二的步骤就可以省略,因此平均向量 halfVector 这样算:normalize(surfaceToLightDir + surfaceToViewerDirection),最後与法向量做内积

缩小反射范围

如果存档去看渲染结果,看起来像是这样:

rendering-without-pow

显然跟目标撷图不一样,为什麽呢?想想看,如果 halfVector 与法向量相差 60 度,那麽我们做完内积之後,可以获得 0.5 的 specular,这样的反射范围显然太大,我们希望内积之後的值非常接近 1 才能让 specular 有值,再套上 n 次方可以做到这件事,假设 n 为 40,那麽线图看起来像是这样,接近 0.9 时数值才开始明显大於 0:

40-pow

本图撷取自此:https://www.desmos.com/calculator/yfa2jzzejm,读者可以来这边拉左方的 n 值感受一下

其实这个 n 的值可以根据不同物件材质而有所不同,因此加上 u_specularExponent 来控制,同时也加入控制反射光颜色的 uniform,称为 u_specular,笔者在此顺便在 state 中加入对球体、地板不同的 specularExponent:

 async function setup() {
   // ...
   return {
     // ...
     state: {
       // ...
+      ballSpecularExponent: 40,
+      groundSpecularExponent: 100,
     },
     time: 0,
   }
 }

并且把 uniform 传送设定好,反射光设定成白光:

 function render(app) {
   // ...

   twgl.setUniforms(programInfo, {
     u_worldViewerPosition: state.cameraPosition,
     u_lightDir: state.lightDir,
+    u_specular: [1, 1, 1],
   });

   // ...

   { // ball
     // ...
     const worldMatrix = matrix4.multiply(/* ... */);

     twgl.setUniforms(programInfo, {
       // ...
       u_diffuse: [0, 0, 0],
       u_texture: textures.steel,
+      u_specularExponent: state.ballSpecularExponent,
     });
     // ...
   }
   
   { // ground
     // ...
     const worldMatrix = matrix4.multiply(/* ... */);

     twgl.setUniforms(programInfo, {
       // ...
       u_diffuse: [0, 0, 0],
       u_texture: textures.steel,
+      u_specularExponent: state.groundSpecularExponent,
     });
     // ...
   }

最後实做到 fragment shader 内,pow() GLSL 有内建,同时也加上 clamp() 避免数值跑到负的:

 precision highp float;

 uniform vec3 u_diffuse;
 uniform sampler2D u_texture;
 uniform vec3 u_lightDir;
+uniform vec3 u_specular;
+uniform float u_specularExponent;

 varying vec2 v_texcoord;
 varying vec3 v_normal;
 varying vec3 v_surfaceToViewer;

 void main() {
   vec3 diffuse = u_diffuse + texture2D(u_texture, v_texcoord).rgb;  
   vec3 normal = normalize(v_normal);
   vec3 surfaceToLightDir = normalize(-u_lightDir);
   float diffuseBrightness = clamp(dot(surfaceToLightDir, normal), 0.0, 1.0);

   vec3 surfaceToViewerDirection = normalize(v_surfaceToViewer);
   vec3 halfVector = normalize(surfaceToLightDir + surfaceToViewerDirection);
-  float specularBrightness = dot(halfVector, normal);
+  float specularBrightness = clamp(pow(dot(halfVector, normal), u_specularExponent), 0.0, 1.0);

   gl_FragColor = vec4(
-    diffuse * diffuseLight + specularBrightness,
+    diffuse * diffuseLight +
+    u_specular * specularBrightness,
     1
   );

如果把 HTML 对於 ballSpecularExponent, groundSpecularExponent 的控制加上,便可以动态调整反射光的区域:

specular-live-adjustment

本篇的完整程序码可以在这边找到:


<<:  Day 07 : 资料视觉化 Matplotlib

>>:  线性串列的链式储存 - DAY 5

Day 27 - 临时插播的中间件 - Middleware

中间件 - Middleware 嗨大家好,因为在部署 Elastic Beanstalk 上遇到了...

【左京淳的JAVA WEB学习笔记】第一章 软件下载与设定

比起JAVA档可以直接在命令列环境下进行练习和测试,JAVA WEB的专案就一定得在服务器(serv...

Day-26 请问 REST 是什麽? GET 和 POST 是什麽?

传说中的 REST (表现层状态转换) 出现了!一个不太好解释的名词,但面试我还真的碰到了…初心者...

Day 08 设置关键字的基本 sense

在设置关键字的时候,有些 NG 行为是不能犯的,今天就想跟大家聊聊,当我们在揣测消费者的心思时,哪些...

学习Python纪录Day29 - 简易版会飞的小鸟

铁人赛学习纪录来到了第28天,今天想来做点轻松的,决定用python做一只会飞的小鸟 首先大概设计一...