# 1. WebGL之文字-HTML
这里的核心就是如何将html
的位置跟三维场景中的东西关联。
本质上还是裁剪空间坐标和像素空间坐标的变换。下面是裁剪空间到像素空间坐标的转换过程。
// 从裁剪空间转换到像素值
const pixelX = (clipspaceX * 0.5 + 0.5) * gl.canvas.width;
const pixelY = (clipspaceP * -0.5 + 0.5) * gl.canvas.height;
2
3
核心代码
...
gl.drawArrays(...);
// 我们刚计算出绘制三维 F 的矩阵
// 选择物体空间中 'F' 的一点
// X Y Z W
var point = [100, 0, 0, 1]; // 这是正面的右上角
// 使用矩阵计算出这一点的变换后的坐标
var clipspace = m4.transformVector(matrix, point);
// 将 X 和 Y 除以 W,和 GPU 一样
clipspace[0] /= clipspace[3];
clipspace[1] /= clipspace[3];
// 从裁剪空间转换到像素值
var pixelX = (clipspace[0] * 0.5 + 0.5) * gl.canvas.width;
var pixelY = (clipspace[1] * -0.5 + 0.5) * gl.canvas.height;
// 定位 div
div.style.left = Math.floor(pixelX) + "px";
div.style.top = Math.floor(pixelY) + "px";
textNode.nodeValue = clock.toFixed(2);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
视觉效果如下图所示:
demo
地址 WebGL之文字-HTML (opens new window)
# 2. WebGL之文字-二维Canvas
除了使用 HTML
元素显示文字,还可以使用一个新画布,但是使用的是二维上下文。少一些灵活性, 比如不能使用好用的 CSS
样式了,但是也不需要创建并保持 HTML
元素了。
核心代买如下所示
- 创建一个新的
canvas
<style>
.container {
position: relative;
}
#text {
position: absolute;
left: 0px;
top: 0px;
z-index: 10;
}
</style>
<div class="container">
<canvas id="canvas"></canvas>
<canvas id="text"></canvas>
</div>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- 创建
canvas
上下文
// 找到画布
const textCanvas = document.querySelector("#text");
// 创建一个二维上下文
const ctx = textCanvas.getContext("2d");
2
3
4
- 绘制文字
function drawScene(clock) {
...
// 清空二维画布
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
...
// 计算透透视投影矩阵
const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
const projectionMatrix =
m4.perspective(fieldOfViewRadians, aspect, 1, 2000);
const spread = 170;
for (const yy = -1; yy <= 1; ++yy) {
for (const xx = -2; xx <= 2; ++xx) {
let matrix = m4.translate(projectionMatrix,
translation[0] + xx * spread, translation[1] + yy * spread, translation[2]);
matrix = m4.xRotate(matrix, rotation[0]);
matrix = m4.yRotate(matrix, rotation[1] + yy * xx * 0.2);
matrix = m4.zRotate(matrix, rotation[2] + (yy * 3 + xx) * 0.1);
matrix = m4.translate(matrix, -50, -75, 0);
// Set the matrix.
gl.uniformMatrix4fv(matrixLocation, false, matrix);
gl.drawArrays(gl.TRIANGLES, 0, 16 * 6);
const clipspace = m4.transformVector(matrix, [100, 0, 0, 1]);
// divide X and Y by W just like the GPU does.
clipspace[0] /= clipspace[3];
clipspace[1] /= clipspace[3];
const pixelX = (clipspace[0] * 0.5 + 0.5) * gl.canvas.width;
const pixelY = (clipspace[1] * -0.5 + 0.5) * gl.canvas.height;
// 绘制箭头和文字
// 保存画布设置
ctx.save();
// 将画布原点移动到 F 的正面右上角
ctx.translate(pixelX, pixelY);
// 开支绘制箭头
ctx.beginPath();
ctx.moveTo(10, 5);
ctx.lineTo(0, 0);
ctx.lineTo(5, 10);
ctx.moveTo(0, 0);
ctx.lineTo(15, 15);
ctx.stroke();
// 开始绘制文字
ctx.fillText("" + xx + "," + yy, 20, 20);
// 还原画布设置
ctx.restore();
}
}
requestAnimationFrame(drawScene);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
demo
地址 WebGL之文字-二位canvas (opens new window)
# 3. WebGL之文字-使用纹理
这种方式我们需要使用WebGL
绘制两个不同的物体。物体和文字需要用到单独的着色器。
# 1.物体的着色器
// 顶点着色器
<script id="vertex-shader-3d" type="x-shader/x-vertex">
attribute vec4 a_position;
attribute vec4 a_color;
uniform mat4 u_matrix;
varying vec4 v_color;
void main() {
gl_Position = u_matrix * a_position;
v_color = a_color;
}
</script>
// 片段着色器
<script id="fragment-shader-3d" type="x-shader/x-fragment">
precision mediump float;
varying vec4 v_color;
void main() {
gl_FragColor = v_color;
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 2.文字的着色器
// 顶点着色器
<script id="text-vertex-shader" type="x-shader/x-vertex">
attribute vec4 a_position;
attribute vec2 a_texcoord;
uniform mat4 u_matrix;
varying vec2 v_texcoord;
void main() {
gl_Position = u_matrix * a_position;
v_texcoord = a_texcoord;
}
</script>
//片元着色器
<script id="text-fragment-shader" type="x-shader/x-fragment">
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D u_texture;
uniform vec4 u_color;
void main() {
gl_FragColor = texture2D(u_texture, v_texcoord) * u_color;
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 3.创建物体和单位矩形
// 创建 'F' 的数据
const fBufferInfo = primitives.create3DFBufferInfo(gl);
// 创建一个单位矩形供文字使用
const textBufferInfo = primitives.createPlaneBufferInfo(gl, 1, 1, 1, 1, m4.xRotation(Math.PI / 2));
2
3
4
单位矩形是一个单位大小的矩形(正方形),这个矩形以原点为中心。createPlaneBufferInfo
创建一个在 xz
面的平面,我们将它绕x
轴旋转了90°
变成 xy
平面的单位矩形。
# 4.创建着色器
const fProgramInfo = createProgramInfo(gl, ["vertex-shader-3d", "fragment-shader-3d"]);
const textProgramInfo = createProgramInfo(gl, ["text-vertex-shader", "text-fragment-shader"]);
2
# 5.创建文字纹理
// 创建文字纹理
var textCanvas = makeTextCanvas("Hello!", 100, 26);
var textWidth = textCanvas.width;
var textHeight = textCanvas.height;
var textTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, textTex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textCanvas);
// 确保即使不是 2 的整数次幂也能渲染
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
2
3
4
5
6
7
8
9
10
11
# 6.绘制物体
gl.useProgram(fProgramInfo.program);
webglUtils.setBuffersAndAttributes(gl, fProgramInfo, fBufferInfo);
fUniforms.u_matrix = m4.multiply(projectionMatrix, fViewMatrix);
webglUtils.setUniforms(fProgramInfo, fUniforms);
// 绘制几何体
gl.drawElements(gl.TRIANGLES, fBufferInfo.numElements, gl.UNSIGNED_SHORT, 0);
2
3
4
5
6
# 7.绘制文字
// 只使用 'F' 视图矩阵的位置
let textMatrix = m4.translate(projectionMatrix,
fViewMatrix[12], fViewMatrix[13], fViewMatrix[14]);
// 缩放单位矩形到所需大小
textMatrix = m4.scale(textMatrix, textWidth, textHeight, 1);
// 绘制文字设置
gl.useProgram(textProgramInfo.program);
webglUtils.setBuffersAndAttributes(gl, textProgramInfo, textBufferInfo);
// 将textMatrix复制给textUniforms.u_matrix
m4.copy(textMatrix, textUniforms.u_matrix);
webglUtils.setUniforms(textProgramInfo, textUniforms);
// 绘制文字
gl.drawElements(gl.TRIANGLES, textBufferInfo.numElements, gl.UNSIGNED_SHORT, 0);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
此时你会发现文字部分会覆盖 F
物体,因为我们绘制了一个矩形,画布的默认颜色是黑色透明 (0,0,0,0)
, 然后我们将它绘制到矩形上了。
为了解决这个问题,我们需要混合像素。
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
2
gl.blendFunc
指定了混合像素的方式。第一个参数是源像素,第二个参数是目标像素,根据上面的blendFunc
函数,可以得到如下的计算公式。
result = dest * (1 - src_alpha) + src * src_alpha
所以如果目标像素是绿色 0,1,0,1
,源是红色 1,0,0,1
就得到
src = [1, 0, 0, 1]
dst = [0, 1, 0, 1]
src_alpha = src[3] // 这是 1
result = dst * (1 - src_alpha) + src * src_alpha
// 相当于
result = dst * 0 + src * 1
// 最后结果
result = src
2
3
4
5
6
7
8
对于黑色透明的部分的纹理 0,0,0,0
src = [0, 0, 0, 0]
dst = [0, 1, 0, 1]
src_alpha = src[3] // 这是 0
result = dst * (1 - src_alpha) + src * src_alpha
// 相当于
result = dst * 1 + src * 0
// 最后结果
result = dst
2
3
4
5
6
7
8
尽管使用了混合像素
,但是上面的覆盖问题还是存在。因为我们现在是绘制一个 F
然后绘制文字,然后绘制下一个 F
和文字。
那是因为我们还用到了深度缓冲
,所以当绘制一个 F
的文字时, 即使使用混合模式保留了背景色,但是深度缓冲还是会更新,当绘制下一个 F
时如果那个 F
的某些部分在之前文字像素的后面,那些部分就不会绘制。这个问题其实是 GPU
渲染三维时的最难解决的问题,透明出现问题。
对与透明渲染常用的解决方法是先渲染不透明的物体,然后按照 z
的顺寻绘制透明物体, 绘制时开启深度检测但是关闭深度缓冲更新。
先将绘制的不透明物体(F
)和 透明物体区分开(文字),先定义一些东西保存文字的位置
let fViewMatrix = m4.translate(viewMatrix,
translation[0] + xx * spread, translation[1] + yy * spread, translation[2]);
fViewMatrix = m4.xRotate(fViewMatrix, rotation[0]);
fViewMatrix = m4.yRotate(fViewMatrix, rotation[1] + yy * xx * 0.2);
fViewMatrix = m4.zRotate(fViewMatrix, rotation[2] + now + (yy * 3 + xx) * 0.1);
fViewMatrix = m4.scale(fViewMatrix, scale[0], scale[1], scale[2]);
fViewMatrix = m4.translate(fViewMatrix, -50, -75, 0);
// 保存 文字 的视图位置
textPositions.push([fViewMatrix[12], fViewMatrix[13], fViewMatrix[14]]);
2
3
4
5
6
7
8
9
绘制 F
前关闭混合模式, 开启深度缓冲
gl.disable(gl.BLEND);
gl.depthMask(true);
2
绘制文字开启混合,关闭深度缓冲写入
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.depthMask(false);
2
3
另一个问题是文字和对应的 F
相交了,这其实没有一个明确的解决办法。常规解决方案就是将文字沿着向量朝眼睛方向移动一定距离。
// 由于 pos 在视图空间,表示它是一个从眼睛位置出发的一个向量
// 所以沿着向量朝眼睛方向移动一定距离
const fromEye = m4.normalize(pos);
const amountToMoveTowardEye = 150; // 因为 F 是 150 个单位长
const viewX = pos[0] - fromEye[0] * amountToMoveTowardEye;
const viewY = pos[1] - fromEye[1] * amountToMoveTowardEye;
const viewZ = pos[2] - fromEye[2] * amountToMoveTowardEye;
const textMatrix = m4.translate(projectionMatrix, viewX, viewY, viewZ);
2
3
4
5
6
7
8
但是还是可能还会发现文字边缘的问题。
这个问题是 Canvas 2D API
只生成预乘阿尔法通道的值,当我们上传画布内容为WebGL
纹理时, WebGL
视图获取没有预乘阿尔法的值,但是由于预乘阿尔法的值缺失阿尔法,所以很难完美转换成非预乘值。
解决这个问题需要告诉WebGL不用做反预乘。
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
这个告诉 WebGL
提供预乘值到gl.texImage2D
和 gl.texSubImage2D
, 如果像 Canvas 2D
数据本身就是预乘的话,就直接传递到WebGL
。
我们还需要修改混合方法
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
旧的方法将源和它的阿尔法通道相乘,就是 SRC_ALPHA
代表的意思。 但是现在我们的纹理数据已经乘了它的阿尔法值,就是预乘的意思。 所以就不需要让 GPU
再做乘法,设置为 ONE
表示乘以 1
。
如果你想让文字保持固定大小怎么办? 如果你还记得透视投影 种讲到过透视矩阵就是将物体缩放 1 / -Z
,以实现近大远小。所以,我们只需缩放 -Z
的期望倍数。
...
// 由于 pos 在视图空间,表示它是一个从眼睛位置出发的一个向量
// 所以沿着向量朝眼睛方向移动一定距离
const fromEye = normalize(pos);
const amountToMoveTowardEye = 150; // 因为 F 是 150 个单位长
const viewX = pos[0] - fromEye[0] * amountToMoveTowardEye;
const viewY = pos[1] - fromEye[1] * amountToMoveTowardEye;
const viewZ = pos[2] - fromEye[2] * amountToMoveTowardEye;
const desiredTextScale = -1 / gl.canvas.height; // 1x1 像素大小
const scale = viewZ * desiredTextScale;
const textMatrix = m4.translate(projectionMatrix, viewX, viewY, viewZ);
// 将矩形缩放到需要的大小
textMatrix = m4.scale(textMatrix, textWidth * scale, textHeight * scale, 1);
...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
参考文档
WebGL文字 - HTML (opens new window)
WebGL 文字 - 二维Canvas (opens new window)
WebGL 文字 - 使用纹理 (opens new window)