Orthogonal 3D 投影

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

"Orthogonal" 查询字典的话得到的意思是:直角的,正交的,垂直的,而 orthogonal 3D projection 笔者的理解是:从 3D 场景中选取一个长方体区域作为 clip space 投影到画布上,事实上与先前 2D 投影非常类似,只是多一个维度 z,在 orthogonal 3D 投影时叫做深度,本篇的目标将以 orthogonal 3D 投影的方式渲染一个 P 文字形状的 3D 物件

03-3d-objects.html / 03-3d-objects.html

这个章节使用新的一组 .html / .js 作为开始,完整程序码可以在这边找到:github.com/pastleo/webgl-ironman/commit/bc7c806,绝大部分的程序码在先前的章节都有相关的说明了,以下几点比较值得注意的:

  • vertex attribute 有:a_position, a_colora_color 作为 fragment shader 输出颜色使用,显示的结果将会是一个一个由 a_color 指定的色块(或是渐层,如果一个三角形内的颜色不同的话)
  • lib/matrix.js 内实做了三维运算需要的 matrix4 系列 function,同样因为平移需要再加上一个运算时多余的维度而成为四维矩阵
    • 在三维场景中能够以 x 轴、y 轴、z 轴旋转,因此旋转部份有 xRotate, yRotate, zRotate
  • a_position, a_color 在起始点的 buffer 传入空阵列、viewMatrix, worldMatrix 起始点为 matrix4.identity(),什麽都不做、gl.drawArrays(gl.TRIANGLES, 0, 0); 绘制 0 个顶点,这些都将在本篇补上
  • 本篇不会用到动画效果,因此 startLoop 被注解不会用到

仔细看的话,会发现在 vertex shader 中的 a_position 宣告型别为 vec4,可以直接与 4x4 矩阵 u_matrix 相乘,但是在设定传入的资料时候 gl.vertexAttribPointer() 指定的长度只有 3,那麽剩下的向量第四个元素(接下来称为 w)的值怎麽办?有意思的是,在提供的资料长度不足或是没有提供时 x, y, z 预设值为 0,而 w 很有意思地预设值是 1 ,对於所有 vec4 的 vertex attribute 都是如此,这样一来就可以符合平移时多余维度为 1 的需求,很巧的是,如果今天这个 attribute 是颜色,那意义上就变成 alpha 预设值是 1

P 文字形状的 3D 物件

以下是笔者在设计时画的图:

P-model

  • 最左边的图表示此物件正面的样子,第二张为从上方透视的底面,这两张表示了各个顶点的编号
    • 同时以 a, b, c, d 表示特定边长的长度,以便为各个顶点定位座标
  • 右边两张图表示各个长方形的编号,最右边的图表示从上方透视的底面

这边的任务是要为此 3D 物件产生对应的 a_position, a_color 阵列,可以想像要建立的资料不少,直接写在 setup() 内很快就会让 setup() 失去焦点,因此建立 function createModelBufferArrays(),选定 a, b, c, d 的数值後第一步骤就是产生各个顶点的座标:

function createModelBufferArrays() {
  // positions
  const a = 40, b = 200, c = 60, d = 45;

  const points = [0, d].flatMap(z => ([
    [0, 0, z], // 0, 13
    [0, b, z],
    [a, b, z],
    [a, 0, z],
    [2*a+c, 0, z], // 4, 17
    [a, a, z],
    [2*a+c, a, z],
    [a, 2*a, z],
    [2*a+c, 2*a, z], // 8, 21
    [a, 3*a, z],
    [2*a+c, 3*a, z],
    [a+c, a, z],
    [a+c, 2*a, z], // 12, 25
  ]));
}

在上图中顶点的编号对应 points 阵列中的 index 值,因为正面、底面的座标位置只有 z 轴前後的差别,因此用 flatMap 让程序码更少一点,笔者在程序码中某些座标的右方有注解表示其对应的顶点编号

不过 points 不能当成 a_position 阵列,a_position 阵列必须是一个个三角形的顶点,以P 文字形状的 3D 物件来说,所以的面都可以由长方形组成,两个三角形可以形成一个长方形,因此写一个 function 接受四个顶点座标,产生两个三角形的 a_position 阵列:

function rectVertices(a, b, c, d) {
  return [
    ...a, ...b, ...c,
    ...a, ...c, ...d,
  ];
}

有了这个工具之後,a_position 就可以以长方形为单位写成:

const a_position = [
  ...rectVertices(points[0], points[1], points[2], points[3]), // 0
  ...rectVertices(points[3], points[5], points[6], points[4]),
  ...rectVertices(points[7], points[9], points[10], points[8]),
  ...rectVertices(points[11], points[12], points[8], points[6]),
  ...rectVertices(points[13], points[16], points[15], points[14]), // 4
  ...rectVertices(points[16], points[17], points[19], points[18]),
  ...rectVertices(points[20], points[21], points[23], points[22]),
  ...rectVertices(points[24], points[19], points[21], points[25]),
  ...rectVertices(points[0], points[13], points[14], points[1]), // 8
  ...rectVertices(points[0], points[4], points[17], points[13]),
  ...rectVertices(points[4], points[10], points[23], points[17]),
  ...rectVertices(points[9], points[22], points[23], points[10]),
  ...rectVertices(points[9], points[2], points[15], points[22]), // 12
  ...rectVertices(points[2], points[1], points[14], points[15]),
  ...rectVertices(points[5], points[7], points[20], points[18]),
  ...rectVertices(points[5], points[18], points[24], points[11]),
  ...rectVertices(points[11], points[24], points[25], points[12]), // 16
  ...rectVertices(points[7], points[12], points[25], points[20]),
];

同样地,在 a_position 阵列中某些 rectVertices() 呼叫右方有注解表示其对应在上图中的长方形

完成 a_position 之後,a_color 也要为三角形每个顶点产生对应资料,笔者的设计是一个平面使用一个颜色,也就是一个长方形(两个三角形,共 6 个顶点)的颜色至少都会一样,同时希望面的颜色是随机,因此写了以下 function:

function rectColor(color) {
  return Array(6).fill(color).flat();
}

function randomColor() {
  return [Math.random(), Math.random(), Math.random()];
}

笔者私心想要正面颜色使用笔者的主题颜色,除此之外随机产生,因此 a_color 的产生如下:

// a_color
const frontColor = [108/255, 225/255, 153/255];
const backColor = randomColor();
const a_color = [
  ...rectColor(frontColor), // 0
  ...rectColor(frontColor),
  ...rectColor(frontColor),
  ...rectColor(frontColor),
  ...rectColor(backColor), // 4
  ...rectColor(backColor),
  ...rectColor(backColor),
  ...rectColor(backColor),
  ...rectColor(randomColor()), // 8
  ...rectColor(randomColor()),
  ...rectColor(randomColor()),
  ...rectColor(randomColor()),
  ...rectColor(randomColor()), // 12
  ...rectColor(randomColor()),
  ...rectColor(randomColor()),
  ...rectColor(randomColor()),
  ...rectColor(randomColor()), // 16
  ...rectColor(randomColor()),
];

最後回传整个『P 文字形状的 3D 物件』相关的全部资料,除了 vertex attributes 之外,待会 gl.drawArrays() 需要知道要绘制的顶点数量,在这边也以 numElements 回传:

return {
  numElements: a_position.length / 3,
  attribs: {
    a_position, a_color,
  },
}

Orthogonal 3D 绘制

辛苦写好了 createModelBufferArrays,当然得在 setup() 呼叫,把 attribute 资料传送到对应的 buffer 内:

// async function setup() {
// ...
const modelBufferArrays = createModelBufferArrays();

// ...

gl.bufferData(
  gl.ARRAY_BUFFER,
  new Float32Array(modelBufferArrays.attribs.a_position),
  gl.STATIC_DRAW,
);

// ...

gl.bufferData(
  gl.ARRAY_BUFFER,
  new Float32Array(modelBufferArrays.attribs.a_color),
  gl.STATIC_DRAW,
);

// ...

return {
  gl,
  program, attributes, uniforms,
  buffers, modelBufferArrays,
  state: {
  },
  time: 0,
};

同时也把 modelBufferArrays 也回传,在 render() 使用,并让 viewMatrix 使用 matrix4.projection() 产生,orthogonal projection 就是在这行发生:

function render(app) {
   const {
     gl,
     program, uniforms,
+    modelBufferArrays,
     state,
   } = app;
   
   // ...

-  const viewMatrix = matrix4.identity();
+  const viewMatrix = matrix4.projection(gl.canvas.width, gl.canvas.height, 400);
   const worldMatrix = matrix4.identity();
  
   // ...
  
-  gl.drawArrays(gl.TRIANGLES, 0, 0);
+  gl.drawArrays(gl.TRIANGLES, 0, modelBufferArrays.numElements);
 }

存档,来看看结果:

p-incorrect-front-color

因为是其他颜色是随机产生,读者跟着跑到这边的话看到的不一定是这个颜色

有东西是有东西,但是正面颜色怎麽不是主题色?像是这张图的底色才是笔者的主题色:

bottom-color-is-the-theme

事实上,在 WebGL 绘制时,假设先绘制了一个三角形,然後再绘制了一个三角形在同一个位置,那麽後面的三角形会覆盖掉之前的颜色,也就是说当前看到的颜色是底面的颜色(上方程序码中的 backColor),解决这个问题的其中一个方法是启用『只绘制正面面向观看者的三角形』功能 gl.CULL_FACE,当三角形的顶点顺序符合右手开掌的食指方向时,大拇指的方向即为三角形正面,如下图所示的长方形(或是说两个三角形)的正面朝观看者:

cull-face

笔者已经在上方建立 a_position 时使得组成底面的三角形面向下,因此只要加上这行:

gl.enable(gl.CULL_FACE);

底面就会因为面向下,其正面没有对着观看者,不会被绘制,可以看到正面了:

p-correct-color

转一下,看起来比较 3D

因为使用 matrix4.projection() 做 orthogonal 投影,其实就是投影到 xy 平面上,这个 3D 模组投影下去看不出来是 3D 的,因此串上各个 transform,尤其是旋转,使之看起来真的是 3D,首先在 setup() 回传初始 tranform 值:

// async function setup() {
// ...
return {
  gl,
  program, attributes, uniforms,
  buffers, modelBufferArrays,
  state: {
    projectionZ: 400,
    translate: [150, 100, 0],
    rotate: [degToRad(30), degToRad(30), degToRad(0)],
    scale: [1, 1, 1],
  },
  time: 0,
};

可以注意到除了 translate, rotate, scale 之外,笔者也加上 projectionZ,之後让使用者可以控制 z 轴 clip space,接着在 render() 串上 worldMatrix 矩阵的产生:

const viewMatrix = matrix4.projection(gl.canvas.width, gl.canvas.height, state.projectionZ);
const worldMatrix = matrix4.multiply(
  matrix4.translate(...state.translate),
  matrix4.xRotate(state.rotate[0]),
  matrix4.yRotate(state.rotate[1]),
  matrix4.zRotate(state.rotate[2]),
  matrix4.scale(...state.scale),
);

结果看起来像是这样,是 3D 了,但是显然怪怪的:

without-depth-test

在上面已经启用 gl.CULL_FACE,不面向观看者的面确实不会被绘制,但是不够,下图箭头指着的面是面向观看者的,因为在 a_position 上排列在正面之後,导致正面绘制之後被面覆盖过去:

without-depth-test-problem-surfaces

调换 a_position 或许可以解决,不过如果让使用者可以旋转,旋转到背面时那麽又会露出破绽,因此需要另一个功能:gl.DEPTH_TEST,也就是深度测试,在 vertex shader 输出的 gl_Position.z 除了给 clip space 之外,也可以作为深度资讯,如果准备要画上的 pixel 比原本画布上的来的更接近观看者,颜色才会覆盖上去,因此加入这行启用这个功能:

gl.enable(gl.DEPTH_TEST);

耶,一切就正确罗:

correct-3d-p

最後笔者也加入使用者控制 transform 功能,本篇完整程序码可以在这边找到:

不过使用 orthogonal 投影方法画出来的画面与我们在生活中从眼睛、相机看到的其实不同,待下篇来介绍更接近现实生活眼睛看到的成像方式:Perspective projection


<<:  Day3-丛集是在集什麽 何谓丛集(cluster)

>>:  Day05 永丰金API 基础流程 -- Sign

关於伪类 ( pseudo-class )

伪类选取器 ( pseudo-class ) 或是也被称为拟态选择器,可以用来设定 HTML 元素在...

Android学习笔记10

今天来用kotlin实作一个BaseActivity,方便以後跳页传值使用 fun start(ne...

Day28 vue.js搜寻栏 分页(pagination)功能

延续昨日 今天我们且战且走 首先先把最简单的排序专案方法搞定 先创一个sortby function...

铁人赛完赛整理&开源

第12 届iT邦帮忙铁人赛系列文章 (Day30) 终於走到这一天了,每次都觉得铁人赛过程都生不如死...

Day 26 - CDK 建置 Amazon Elastic Kubernetes Service(EKS)- Service

昨天介绍完了 Cluster 今天来介绍如何使用 CDK 建立 EKS Service 往常我们如果...