帆船与海

大家好,我是西瓜,你现在看到的是 2021 iThome 铁人赛『如何在网页中绘制 3D 场景?从 WebGL 的基础开始说起』系列文章的第 30 篇文章。本系列文章从 WebGL 基本运作机制以及使用的原理开始介绍,本文将加入一些海面效果作为本系列文的完结作品:帆船与海,如果在阅读本文时觉得有什麽未知的东西被当成已知的,可能可以在前面的文章中找到相关的内容

有了帆船天空、反射天空的海面以及简易的操作说明,要说这个是成品应该是没什麽问题,不过看着那固定的海面以及帆船,如果能让他们动起来是不是更好?

Shadertoy

这其实是一个网站:https://www.shadertoy.com/

像是 codepen, jsfiddle, jsbin 那样,在网页中写程序,然後在旁边跑起来呈现结果,同时网站也是分享的平台,只是 shadertoy 是专门写 WebGL shader 的,而且能写的是 fragment shader,主要的资料来源只有当下 fragment shader 绘制的 pixel 位置以及时间,剩下的就是发挥使用者的想像力(以及数学)来画出绚丽的画面,可以在这边看到许多别人写好的 shader,像是:

可以看到这些 shader 的实做蛮复杂的,究竟是什麽演算法使得只透过简单的资料来源就能得到这麽漂亮的效果?笔者看了 Youtube 频道 -- The Art of Code 的一些影片之後,了解到这些 shader 多少运用到了 hash & noise 的技巧,这些伪随机函数接收二或三维度的座标,接着回传看似随机的数值,通常介於 0~1,那麽我们就可以利用这个数字当成一个地方的云层密度、火焰强度、海浪高度

回到想要让海面动起来的问题,除了上述的技巧之外,笔者找到一个水面效果的 shadertoy,效能蛮好的,有许多地方可以拿来参考,接下来就看要怎麽实做在 oceanFragmentShaderSource

动态的海面法向量 oceanNormal

海面反光计算、倒影阴影的计算都会利用到海面的法向量,在 Day 21 使用一个 texture 作为 normal map 使得表面可以利用法向量的变化产生凹凸细节,只可惜这一张图不会动

vec3 normal = texture2D(u_normalMap, v_texcoord * 256.0).xyz * 2.0 - 1.0;

要让海面动起来,也就是这个 normal 要可以根据时间改变,已经有 uniform float u_time;,早在这一章节起始点就已经准备好了,其值为 app.time / 1000,大约每一秒加一;接着设计一个类似於 shadertoy 那样的 function,透过海面的 xz 座标来产生伪随机的法向量:

+vec3 oceanNormal(vec2 pos);
+
 void main() {
   vec2 reflectionTexcoord = (v_reflectionTexcoord.xy / v_reflectionTexcoord.w) * 0.5 + 0.5;
-  vec3 normal = texture2D(u_normalMap, v_texcoord * 256.0).xyz * 2.0 - 1.0;
+  vec3 normal = oceanNormal(v_worldSurface.xz);

   reflectionTexcoord += normal.xy * 0.1;
   // ...
 }

+vec3 oceanNormal(vec2 position) {
+}

不知不觉这篇可能会变成以 C 语言为主,在 main() 之前要先宣告 vec3 oceanNormal(vec2 pos) 的存在,要不然编译会失败

接下来假设会实做一个 function,带入一组 xz 座标会得到 xyz 填上高度,把这个 function 叫做 vec3 oceanSurfacePosition(vec2 position),那麽 oceanNormal() 就可以呼叫 oceanSurfacePosition() 三次,第一次带入原始的 p1: [x, z]、第二次 p2: [x + 0.01, z]、第三次 p3: [x, z + 0.01],拿 p1 -> p2 以及 p1 -> p3 两个向量做外积就可以得到一定准度的法向量,oceanNormal() 实做如下:

#define OCEAN_SAMPLE_DISTANCE 0.01
vec3 oceanNormal(vec2 position) {
  vec3 p1 = oceanSurfacePosition(position);
  vec3 p2 = oceanSurfacePosition(position + vec2(OCEAN_SAMPLE_DISTANCE, 0));
  vec3 p3 = oceanSurfacePosition(position + vec2(0, OCEAN_SAMPLE_DISTANCE));

  return normalize(cross(
    normalize(p2 - p1), normalize(p3 - p1)
  ));
}

波浪函数

让假设会有的 oceanSurfacePosition() function 成为事实:

vec3 oceanSurfacePosition(vec2 position) {
  float height = 0.0;
  return vec3(position, height);
}

为了符合 normal 以 [0, 0, 1] 为上面,height 输出到 z 的位置,也就跟 normal map 取得的 vec3 排列一致

现在要求的是运算 position 来取得 height,笔者在观察 水面效果的 shadertoy 时发现其第 15 行:

float wave = exp(sin(x) - 1.0);

输入到 desmos 可以看到漂亮的波浪形状:

wave-function-graph

sin() 的波形本身就是於 +1 ~ -1 之间不停来回,减 1 套 exp() 指数函数 得到最大值为 1 的波浪函数,实做给 height 试试看

vec3 oceanSurfacePosition(vec2 position) {
  float height = exp(sin(position.x) - 1.0);
  return vec3(position, height);
}

缩小预览,可以看到规律的波纹与 z 轴平行:

wave-along-x

如果想要让帆船的方向与海浪垂直、并且使之跟着时间而波动,定义一个角度以及向量 direction 与位置进行内积,加上时间成为波浪函数的 x 轴输入值 waveX:

vec3 oceanSurfacePosition(vec2 position) {
  float directionRad = -2.355;
  vec2 direction = vec2(cos(directionRad), sin(directionRad));

  float waveX = dot(position, direction) * 2.5 + u_time * 5.0;
  float height = exp(sin(waveX) - 1.0);

  return vec3(position, height);
}

-2.3553.14-0.75 倍,也就是反向旋转 135 度;将 dot(position, direction) 乘上 2.5 可以缩小波长、时间乘上 5.0 加快波浪速度

波浪就动起来罗:

waving

但是这样的波浪未免也太规律,这时来使用上方说到的 hash / noise 的伪随机技巧,先实做这个 Youtube 影片 -- Shader Coding: Making a starfield - Part 1 by The Art of Code 中的 hash() function:

float hash(vec2 p) {
  p = fract(p * vec2(234.83, 194.51));
  p += dot(p, p + 24.9);
  return fract(p.x * p.y);
}

fract() 用来取小数,这整个 function 其实没有什麽道理,其中的 234.83, 194.51 等数字也可以随便改,某种程度算是 seed 吧,总之输入一个二维向量,得到介於 0~1 的数字

算式虽然是说没什麽道理,但是笔者自己随便写一个,要达到一个随机的感觉其实不容易,可能到数字很大的时候还是会出现特定的规律

接着使用类似此 Youtube 影片 -- Value Noise Explained! by The Art of Code 的技巧,利用 floor() 把海面以每个整数分成一格一格,例如 1~1.99999... 为一格,用 id 表示当前的格子,每个格子使用同一个 hash 值,拿 hash 出来的伪随机值当成 directionRad 角度偏移量:

 vec3 oceanSurfacePosition(vec2 position) {
-  float directionRad = -2.355;
+  vec2 id = floor(position);
+
+  float directionRad = (hash(id) - 0.5) * 0.785 - 2.355;
   vec2 direction = vec2(cos(directionRad), sin(directionRad));

   float waveX = dot(position, direction) * 2.5 + u_time * 5.0;
   float height = exp(sin(waveX) - 1.0);

   return vec3(position, height);
 }

因为 hash(id) 得到 0~1 之间的数值,减掉 0.5 成为 -0.5~+0.5,最後乘以 0.785,为 45 度 / 180 * 3.14 而来,这麽一来角度将为 -135 + (-22.5 ~ 22.5),采用 id 分格子之後就变成这样了:

blocks-waving-differently

每格是有不同的方向,但是格子之间都有明显的一条线,解决这个问题的方法是每个格子去计算邻近 1 格的海浪,并且海浪的强度会随着距离来源格子越远而越弱,为此再建立一个 function 叫做 localWaveHeight() 计算一个位置(position)能从一个格子(id)得到多少的海浪高度:

float localWaveHeight(vec2 id, vec2 position) {
  float directionRad = (hash(id) - 0.5) * 0.785 - 2.355;
  vec2 direction = vec2(cos(directionRad), sin(directionRad));

  float distance = length(id + 0.5 - position);
  float strength = smoothstep(1.5, 0.0, distance);

  float waveX = dot(position, direction) * 2.5 + u_time * 5.0;
  return exp(sin(waveX) - 1.0) * strength;
}
  • directionRad, direction, waveX 以及 exp(sin(waveX) - 1.0) 是从 oceanSurfacePosition() 搬过来的
  • distance 透过 length() 计算 positionid 格子中央的距离
  • strength 表示海浪的强度,如果距离为 0 则强度最强为 1,而且我们只打算取到邻近 1 格,距离到达 1.5 时表示已经到达影响力的边缘,这时强度为 0
    • smoothstep(edge0, edge1, x) 可以把输入值 x 介於 edge0 < x < edge1 转换成 0 ~ 1 之间的值,x 超出 edge0 时回传 0、超出 edge1 时回传 1,而且此函数是一个曲线,会平滑地到达边缘,更详细的资料可以参考其维基百科

回到 oceanSurfacePosition(),这时它的任务便是蒐集邻近 id.xy 相差 -1~+1、共 9 个格子对於当下位置的波浪高度,并且加在一起:

vec3 oceanSurfacePosition(vec2 position) {
  vec2 id = floor(position);
  
  float height = 0.0;
  
  for (int i = -1; i <= 1; i++) {
    for (int j = -1; j <= 1; j++) {
      height += localWaveHeight(id + vec2(i, j), position);
    }
  }

  return vec3(position, height);
}

分成格子产生伪随机的波浪角度、对邻近格子进行采样後,看起来真的像是海浪了:

waving-without-block-lines

不过海浪似乎有点太高了,而且希望可以更细致一点,因此在 oceanSurfacePosition() 加入这两行:

 vec3 oceanSurfacePosition(vec2 position) {
+  position *= 6.2;
   vec2 id = floor(position);

   float height = 0.0;

   for (int i = -1; i <= 1; i++) {
     for (int j = -1; j <= 1; j++) {
       height += localWaveHeight(id + vec2(i, j), position);
     }
   }

+  height *= 0.15;

   return vec3(position, height);
 }

position 乘以 6.2 可以使格子更小,height 则是很直觉地乘以 0.15 降低高度,海面就平静许多了,笔者也转一下视角观察反射光的反应:

ocean-wave-complete

好的,海面的部份就到这边,希望读者觉得这样的海面有足够的说服力。既然海面的 normal map texture oceanNormal 已经没有用到,笔者就顺手移除来避免下载不必要的档案

事实上,shadertoy 这样的做法对於实际应用程序上的效能是不利的,理论上应该可以利用原本的 normal map 以更省力的方式达到类似的效果,即时做 hash() 等运算在一些装置上可能会有点跑不动,这麽做其实某种程度上只是笔者觉得这样很有趣,不必依靠别人的 normal map;关於效能问题,这也是为什麽笔者在此章节的右上角加上一个解析度的调整,使用者可以自己选择解析度,如果萤幕以及性能都允许再调到 Retina,例如 iPad pro 或 M1 等级的 apple 装置,反之如果是旧手机可能普通解析度都有点吃力,这时可以选择『低』解析度来顺跑

打磨 -- 帆船的晃动

既然有了波浪,那帆船是不是也该随着时间前後、上下摆动?类似刚刚使用的海浪技巧,只要把时间套 Math.sin() 函数,剩下的就是 3D 物件的 transform:

 function renderBoat(app, viewMatrix, programInfo) {
-  const { gl, textures, objects } = app;
+  const { gl, textures, objects, time } = app;
  
   const worldMatrix = matrix4.multiply(
     matrix4.yRotate(degToRad(45)),
-    matrix4.translate(0, 0, 0),
-    matrix4.scale(1, 1, 1),
+    matrix4.xRotate(Math.sin(time * 0.0011) * 0.03 + 0.03),
+    matrix4.translate(0, Math.sin(time * 0.0017) * 0.05, 0),
   );
   
   // ...
 }

boat-waving-as-well

本系列文的最终成品也就完成罗!完整程序码在此:

感谢各位读者的阅读

对於看到这边的读者,希望这系列文章有让各位学习到东西,笔者其实在踏入业界很早期就知道有 WebGL 这个东西的存在,但是因为要做到有些成果需要非常多基石而一直没有深入下去研究,就如同各位看到的,本系列文於 Day 11 才在画面上出现比较实际的 3D 画面,不过也因此学到非常多东西,已经很久没有这种跳脱舒适圈的感觉了呢

文章中制作的范例主要是示范该文主旨的概念,笔者为了让这些成品比较有成品的感觉,所以有时候会直接在程序码中出现一些调校好的数字,笔者在实做这些范例时当然不会是第一次就完美的,都是一改再改,改完觉得满意了才用一个理想的顺序去描述实做的过程作为文章内容;在本系列文撰写前,笔者学习 WebGL 的练习有放到 github.com/pastleo/webgl-practice,有兴趣的读者可以玩玩後面几个比较完整的练习的 live 版:

WebGL 作为底层的技术,懂得活用其功能,尤其是 shader (以及数学)的话,能制作出的效果肯定是不胜枚举的,本系列文中有许多概念是没有提到,像是 raycast 得到滑鼠、触控位置的 3D 物件迷雾效果等,甚至透过 WebGL 把 GPU 当成无情的运算机器,举 Conway's Game of Life 为例,WebGL 的实做性能显然远超过先前笔者用 rust + webassembly 的版本部落格文章),现在也可以想像的到使用 WebGL 实做的方向:使用 framebuffer,在 fragment shader 读取上回合地图 texture 相关的 cell 来绘制该回合的地图

在最後笔者要感谢 @greggman 撰写的 WebGL2 Fundamentals, WebGL Fundamentals 甚至 Three.js Fundamentals,有完整、深入的教学让笔者可以有系统地学习,补足电脑绘图的知识,在进行 3D 游戏程序设计的时候也更加顺利,因此写下这系列文章分享给各位读者


<<:  Day 20 CSS & HTML5 <CSS的初始化 & HTML5 新增的语意化标签>

>>:  D16 - 「脉冲×宽度×调变」

【Day30】 晋升成铁人龙猫之总结

哈罗~ 今天是铁人赛的最後一天, 来抢个团队中第一发文的位子XD 之前每几日来个小结, 最後一天就来...

[拯救上班族的 Chrome 扩充套件] Chrome Extension 是什麽酷东西? 跟着官方做 Hello Extensions

嗨各位,我是 Robin 今天想跟大家分享如同标题, 到底什麽是 Chrome Extension?...

Day 26 - Vue 与 HTTP请求 (1)

前一天中我们讲解了如何利用Vue CLI快速建立专案,再进入到专案开发之前,还是有一些知识需要恶补的...

微服务的应用程序-会话层是ISO OSI模型的服务程序(即sidecar代理)所属的层

-API 网关和服务网格(来源:Liran Katz) 服务网格(service mesh)是便於...

Day 19 魁儡的 double object

该文章同步发布於:我的部落格 昨天结束了 Matcher 的介绍,今天开始进入 mock 的篇章。...