大家好,我是西瓜,你现在看到的是 2021 iThome 铁人赛『如何在网页中绘制 3D 场景?从 WebGL 的基础开始说起』系列文章的第 10 篇文章。本系列文章从 WebGL 基本运作机制以及使用的原理开始介绍,最後建构出绘制 3D、光影效果之网页。讲完 WebGL 基本机制後,本章节讲述的是 texture 以及 2D transform,如果在阅读本文时觉得有什麽未知的东西被当成已知的,可能可以在前面的文章中找到相关的内容
上篇把原本透过 u_offset
、u_resolution
来控制平移以及 clip space 投影换成只用一个矩阵来做转换,我们实做了矩阵的相乘 (multiply)、平移 (translate) 以及缩放 (scale),在常见的 transform 中还剩下旋转 (rotation) 尚未实做,除此之外 lib/matrix.js
也缺乏一些常用的小工具,本篇将加上平移、缩放、旋转之控制项,同时把这些矩阵工具补完
根据维基百科,可以知道如果原本一个向量为 (x, y)
,旋转 θ 角度後将变成 (x', y')
,那麽公式为:
同时其 tranform 矩阵为:
只不过我们需要的是 3x3 的矩阵,才能符合运算时的需要,多余的维度跟单位矩阵一样,同时记得行列转换成电脑世界使用的惯例(假设要旋转角度为 rad
):
[
Math.cos(rad), Math.sin(rad), 0,
-Math.sin(rad), Math.cos(rad), 0,
0, 0, 1,
]
最後实做在 lib/matrix.js
:
rotate: rad => {
const c = Math.cos(rad), s = Math.sin(rad);
return [
c, s, 0,
-s, c, 0,
0, 0, 1,
]
},
像是速度控制那样,在 HTML 中分别给 X 轴平移、Y 轴平移、缩放、旋转一个 range input:
<!-- <form id="controls"> -->
<!-- ... -->
<div class="py-1">
<label for="translate-x">TranslateX</label>
<input
type="range" id="translate-x" name="translate-x"
min="-150" max="150" value="0"
>
</div>
<div class="py-1">
<label for="translate-y">TranslateY</label>
<input
type="range" id="translate-y" name="translate-y"
min="-150" max="150" value="0"
>
</div>
<div class="py-1">
<label for="scale">Scale</label>
<input
type="range" id="scale" name="scale"
min="0" max="10" value="1" step="0.1"
>
</div>
<div class="py-1">
<label for="rotation">Rotation</label>
<input
type="range" id="rotation" name="rotation"
min="0" max="360" value="0"
>
</div>
<!-- </form> -->
py-1
为模仿 tailwindCSS 的 padding,因为只有这一个 CSS 所以笔者直接实做在 HTML 中:.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
同时也调整一下 HTML 排版,使得右上角控制 UI 看起来像是这样:
接下来在 setup()
中初始化的 app.state
中加入平移、缩放、旋转:
state: {
texture: 0,
offset: [0, 0],
direction: [Math.cos(directionDeg), Math.sin(directionDeg)],
+ translate: [0, 0],
+ scale: 1,
+ rotation: 0,
speed: 0.08,
},
修改矩阵计算之前,把状态与使用者输入事件串好:
controlsForm.addEventListener('input', () => {
const formData = new FormData(controlsForm);
app.state.texture = parseInt(formData.get('texture'));
app.state.speed = parseFloat(formData.get('speed'));
const formData = new FormData(controlsForm);
app.state.texture = parseInt(formData.get('texture'));
app.state.speed = parseFloat(formData.get('speed'));
+ app.state.translate[0] = parseFloat(formData.get('translate-x'));
+ app.state.translate[1] = parseFloat(formData.get('translate-y'));
+ app.state.scale = parseFloat(formData.get('scale'));
+ app.state.rotation = parseFloat(formData.get('rotation')) * Math.PI / 180;
});
在 render()
内,原本 worldMatrix
只有平移转换 translate(...state.offset);
,现在开始也要由多个矩阵相乘:
const worldMatrix = matrix3.multiply(
matrix3.translate(...state.offset),
matrix3.rotate(state.rotation),
);
存档试玩看看:
如果我们想要用图片正中央来旋转而不是左上角呢?在输入顶点位置时,左上角的点为 (0, 0)
:
而 matrix3.rotate()
是基於原点做旋转的,因此调整一下顶点位置,使得原点在正中间:
// a_position
// ...
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([
- 0, 0, // A
- 150, 0, // B
- 150, 150, // C
+ -75, -75, // A
+ 75, -75, // B
+ 75, 75, // C
- 0, 0, // D
- 150, 150, // E
- 0, 150, // F
+ -75, -75, // D
+ 75, 75, // E
+ -75, 75, // F
]),
gl.STATIC_DRAW,
);
不过就没办法做完美的边缘碰撞测试了,笔者就用原点当成碰撞测试点:
// function startLoop(app, now = 0) {
// ...
- if (state.offset[0] + 150 > gl.canvas.width) {
+ if (state.offset[0] > gl.canvas.width) {
state.direction[0] *= -1;
- state.offset[0] = gl.canvas.width - 150;
+ state.offset[0] = gl.canvas.width;
} else if (state.offset[0] < 0) {
state.direction[0] *= -1;
state.offset[0] = 0;
}
- if (state.offset[1] + 150 > gl.canvas.height) {
+ if (state.offset[1] > gl.canvas.height) {
state.direction[1] *= -1;
- state.offset[1] = gl.canvas.height - 150;
+ state.offset[1] = gl.canvas.height;
图片就乖乖的以中心点旋转了:
其实缩放也是从原点出发的,因此这个调整也可以修正待会加入缩放时变成从左上角缩放的问题。笔者学到矩阵 transform 时,似乎就可以感受到 WebGL 的世界为什麽很多东西都是以 -1 ~ +1 作为范围...这样使得原点在正中间,可能在硬体或是 driver 层也更方便使用矩阵做 transform 运算吧
现在 worldMatrix
由 matrix3.translate()
与 matrix3.rotate()
相乘而成,要串上使用者控制的 state.translate
, state.scale
,假设 worldMatrix
要用下面的算式计算而成:
translate(...state.offset) *
rotate(state.rotation) *
scale(state.scale, state.scale) *
translate(...state.translate)
以现成的 matrix3.multiply()
来看会变成这样:
const worldMatrix = matrix3.multiply(
matrix3.multiply(
matrix3.multiply(
matrix3.translate(...state.offset),
matrix3.rotate(state.rotation),
),
matrix3.scale(state.scale, state.scale),
),
matrix3.translate(...state.translate),
);
显然可读性已经大幅下降,换行有波动拳的样子,没换行更惨,之後也会有许多超过两个矩阵依序相乘的状况,因此修改 lib/matrix.js
的 matrix3.multiply()
使之可以接收超过两个矩阵,并递回依序做相乘:
multiply: (a, b, ...rest) => {
const multiplication = [
a[0]*b[0] + a[3]*b[1] + a[6]*b[2], /**/ a[1]*b[0] + a[4]*b[1] + a[7]*b[2], /**/ a[2]*b[0] + a[5]*b[1] + a[8]*b[2],
a[0]*b[3] + a[3]*b[4] + a[6]*b[5], /**/ a[1]*b[3] + a[4]*b[4] + a[7]*b[5], /**/ a[2]*b[3] + a[5]*b[4] + a[8]*b[5],
a[0]*b[6] + a[3]*b[7] + a[6]*b[8], /**/ a[1]*b[6] + a[4]*b[7] + a[7]*b[8], /**/ a[2]*b[6] + a[5]*b[7] + a[8]*b[8],
];
if (rest.length === 0) return multiplication;
return matrix3.multiply(multiplication, ...rest);
},
...rest
的语法叫做 rest parameters,传超过 2 个参数时再呼叫自己将这回合计算的结果继续与剩下的矩阵做计算
回到主程序,worldMatrix
就可以用清楚的语法写了:
const worldMatrix = matrix3.multiply(
matrix3.translate(...state.offset),
matrix3.rotate(state.rotation),
matrix3.scale(state.scale, state.scale),
matrix3.translate(...state.translate),
);
所有的控制就完成了,读者也可以自行调整这些矩阵相乘的顺序,玩玩看所谓『转换顺序』的差别
在总结 2D transform 之前,给 lib/matrix.js
再补上一个 function:
identity: () => ([
1, 0, 0,
0, 1, 0,
0, 0, 1,
]),
这是一个单位矩阵,如果有时候要除错想要暂时取消一些矩阵的转换效果,但是不想修改程序结构:
const worldMatrix = matrix3.multiply(
matrix3.translate(...state.offset),
matrix3.rotate(state.rotation), // 想要暂时取消这行
);
其中一个暂时取消的方式是利用单位矩阵的特性:与其相乘不会改变任何东西,像是这样:
const worldMatrix = matrix3.multiply(
matrix3.translate(...state.offset),
matrix3.identity(),
// matrix3.rotate(state.rotation), // 想要暂时取消这行
);
为了验证,回到主程序修改 worldMatrix
的计算:
const worldMatrix = matrix3.multiply(
matrix3.identity(),
matrix3.translate(...state.offset),
matrix3.rotate(state.rotation),
matrix3.scale(state.scale, state.scale),
matrix3.translate(...state.translate),
);
不论 matrix3.identity()
放在哪个位置,都不会改变结果;上述用途只是其中一个举例,之後可能会因为两个物件共用同一个 shader,但是其中一个物件不需要特定转换,那麽也会传入单位矩阵来『什麽都不做』
本篇的完整程序码可以在这边找到:
Texture & 2D Transform 就到这边,笔者学习到此的时候深刻感受到线性代数的威力,输入的矩阵与理论结合扎实地反应在萤幕上,并为接下来 3D transform 打好基础,下个章节将进入 3D,开始尝试渲染现实世界所看到的样子
<<: AI ninja project [day 10] 基因演算法
border-radius(框线圆角) border-top-left-radius : 左上角显示...
变数 & 记忆体 变数的内容储存於记忆体中,记忆体就像是有很多格子的柜子,每格都会有一个编号...
今天在翻旧code的时候,看到了 Partial 的写法,所幸来查查这到底是什麽意思.弄懂了之後看c...
再做订单的时候,常常会遇到重新付款的需求, 情境通常发生在使用者购物车加入一拖拉库的项目之後, 因为...
事实上一现在的情况来看,若是要用 APCS 成绩当作升大学的跳板是完全不建议的,理由如下: 高手独占...