大家好,我是西瓜,你现在看到的是 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,绝大部分的程序码在先前的章节都有相关的说明了,以下几点比较值得注意的:
a_position
, a_color
,a_color
作为 fragment shader 输出颜色使用,显示的结果将会是一个一个由 a_color
指定的色块(或是渐层,如果一个三角形内的颜色不同的话)lib/matrix.js
内实做了三维运算需要的 matrix4
系列 function,同样因为平移需要再加上一个运算时多余的维度而成为四维矩阵
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
以下是笔者在设计时画的图:
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,
},
}
辛苦写好了 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);
}
存档,来看看结果:
因为是其他颜色是随机产生,读者跟着跑到这边的话看到的不一定是这个颜色
有东西是有东西,但是正面颜色怎麽不是主题色?像是这张图的底色才是笔者的主题色:
事实上,在 WebGL 绘制时,假设先绘制了一个三角形,然後再绘制了一个三角形在同一个位置,那麽後面的三角形会覆盖掉之前的颜色,也就是说当前看到的颜色是底面的颜色(上方程序码中的 backColor
),解决这个问题的其中一个方法是启用『只绘制正面面向观看者的三角形』功能 gl.CULL_FACE
,当三角形的顶点顺序符合右手开掌的食指方向时,大拇指的方向即为三角形正面,如下图所示的长方形(或是说两个三角形)的正面朝观看者:
笔者已经在上方建立 a_position
时使得组成底面的三角形面向下,因此只要加上这行:
gl.enable(gl.CULL_FACE);
底面就会因为面向下,其正面没有对着观看者,不会被绘制,可以看到正面了:
因为使用 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 了,但是显然怪怪的:
在上面已经启用 gl.CULL_FACE
,不面向观看者的面确实不会被绘制,但是不够,下图箭头指着的面是面向观看者的,因为在 a_position
上排列在正面之後,导致正面绘制之後被面覆盖过去:
调换 a_position
或许可以解决,不过如果让使用者可以旋转,旋转到背面时那麽又会露出破绽,因此需要另一个功能:gl.DEPTH_TEST
,也就是深度测试,在 vertex shader 输出的 gl_Position.z
除了给 clip space 之外,也可以作为深度资讯,如果准备要画上的 pixel 比原本画布上的来的更接近观看者,颜色才会覆盖上去,因此加入这行启用这个功能:
gl.enable(gl.DEPTH_TEST);
耶,一切就正确罗:
最後笔者也加入使用者控制 transform 功能,本篇完整程序码可以在这边找到:
不过使用 orthogonal 投影方法画出来的画面与我们在生活中从眼睛、相机看到的其实不同,待下篇来介绍更接近现实生活眼睛看到的成像方式:Perspective projection
<<: Day3-丛集是在集什麽 何谓丛集(cluster)
伪类选取器 ( pseudo-class ) 或是也被称为拟态选择器,可以用来设定 HTML 元素在...
今天来用kotlin实作一个BaseActivity,方便以後跳页传值使用 fun start(ne...
延续昨日 今天我们且战且走 首先先把最简单的排序专案方法搞定 先创一个sortby function...
第12 届iT邦帮忙铁人赛系列文章 (Day30) 终於走到这一天了,每次都觉得铁人赛过程都生不如死...
昨天介绍完了 Cluster 今天来介绍如何使用 CDK 建立 EKS Service 往常我们如果...