半透明的文字看板

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

看着 Day 28 加上天空以及海面上的天空倒影,本章的目标『帆船与海』几乎可以算是完成了:

ocean-reflecting-sunny-sky

但是初次乍到的使用者,除了观赏画面之外应该很难知道操作视角的方式,当然我们可以用 HTML 把说明文字加在画面中,但是这样的话就太没挑战性了,本篇将在场景中加入一段文字简单说明移动视角的方法:

拖曳平移视角

透过滑鼠右键、滚轮
或是多指触控手势
对视角进行转动、缩放

如何在 WebGL 场景中显示文字?

不论是英文、中文还是任何语言,其实显示在画面上时只是一些符号组合在一起形成一幅图,在 WebGL 中并没有『透过某个 API 并输入一个字串,在画面上就会绘制出该文字』这样的事情,但是在 CanvasRenderingContext2D 中有,而且透过 gl.texImage2D() 输入 texture 资料时可以把 <canvas></canvas> DOM 元素喂进去,把画在该 <canvas></canvas> 中的图传送到 texture 上

这麽一来,我们可以:

  1. 建立另一个暂时用的 <canvas></canvas>
  2. 透过 CanvasRenderingContext2D 绘制文字到 <canvas></canvas>
  3. 建立并将暂时的 <canvas></canvas> 输入到 texture
  4. 渲染场景物件时,就当成一般的图片 texture 进行绘制

建立文字 Texture

建立一个 function 叫做 createTextTexture,实做完成时会回传 WebGL texture,在 setup() 中呼叫并接收放在 app.textures.text 中:

 async function setup() {
   // ...
   const textures = twgl.createTextures(gl, {
     // ...
   });

+  textures.text = createTextTexture(gl);
   // ...
 }

+function createTextTexture(gl) {
+}

照着上面的第一步:建立一个暂时用的 <canvas></canvas>:

function createTextTexture(gl) {
  const canvas = document.createElement('canvas');
  canvas.width = 1024;
  canvas.height = 1024;
}

长宽设定成 1024,这样的大小应该可以绘制足够细致的文字。接下来使用 canvas.getContext('2d') 取得 CanvasRenderingContext2D,并绘制文字到 canvas 上:

function createTextTexture(gl) {
  // const canvas = ...

  const ctx = canvas.getContext('2d');

  ctx.clearRect(0, 0, canvas.width, canvas.height);

  ctx.fillStyle = 'white';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  ctx.font = 'bold 80px serif';
  ctx.fillText('拖曳平移视角', canvas.width / 2, canvas.height / 5);

  const secondBaseLine = 3 * canvas.height / 5;
  const secondLineHeight = canvas.height / 7;
  ctx.font = 'bold 70px serif';
  ctx.fillText('透过滑鼠右键、滚轮', canvas.width / 2, secondBaseLine - secondLineHeight);
  ctx.fillText('或是多指触控手势', canvas.width / 2, secondBaseLine);
  ctx.fillText('对视角进行转动、缩放', canvas.width / 2, secondBaseLine + secondLineHeight);
}

这边用的主要是 CanvasRenderingContext2D 的 API,事实上它也可以用来绘制几何图形,不过本文的重点是文字,因此只有使用到相关的功能:

  • 虽然原本就应该是乾净的,不过还是先呼叫 .clearRect() 确保整个画布都是透明黑色 rgba(0, 0, 0, 0)
  • 绘制文字前,.fillStyle 设定文字颜色,而 .textAlign = 'center', .textBaseline = 'middle' 使待会绘制时以从下笔的点进行水平垂直置中
  • .font 设定字型、字体大小
  • .fillText(string, x, y) 如同本文一开始说的『透过某个 API 并输入一个字串,在画面上就会绘制出该文字』,此处的 x, y 为下笔的位置

阅读一下程序码的话,应该不难发现 拖曳平移视角 这行字会是 80px 比接下来的文字(70px)来的大,而且有不少『下笔』位置的计算,总之绘制完毕之後,canvas 看起来像是这样:

text-rendered-on-canvas

有後面的方格是笔者为了避免在文章中什麽都看不到而加上,表示该区域是透明的。canvas 准备好了,如同 Day 6 一样建立、输入 texture,只是先前输入图片的位置改成绘制好的 canvas:

function createTextTexture(gl) {
  // ctx.fillText() ...

  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);

  gl.texImage2D(
    gl.TEXTURE_2D,
    0, // level
    gl.RGBA, // internalFormat
    gl.RGBA, // format
    gl.UNSIGNED_BYTE, // type
    canvas, // data
  );
  gl.generateMipmap(gl.TEXTURE_2D);

  return texture;
}

绘制文字 texture 到场景中 -- 1st try

要绘制文字 texture 到场景上,需要一个 3D 物件来当成此 texture 的载体,绘制在其表面,最适合的就是一个平面了,这样的平面也已经有了,因此直接使用现有的 objects.plane.vao,与其他物件一样建立一个 function 进行绘制:

function renderText(app, viewMatrix, programInfo) {
  const { gl, textures, objects } = app;

  gl.bindVertexArray(objects.plane.vao);

  const textLeftShift = gl.canvas.width / gl.canvas.height < 1.4 ? 0 : -0.9;
  const worldMatrix = matrix4.multiply(
    matrix4.translate(textLeftShift, 0, 0),
    matrix4.xRotate(degToRad(45)),
    matrix4.translate(0, 12.5, 0),
  );

  twgl.setUniforms(programInfo, {
    u_matrix: matrix4.multiply(viewMatrix, worldMatrix),
    u_diffuse: [0, 0, 0],
    u_diffuseMap: textures.text,
  });

  twgl.drawBufferInfo(gl, objects.plane.bufferInfo);
}
  • worldMatrixmatrix4.translate(0, 12.5, 0)matrix4.xRotate(degToRad(45)) 是为了让此文字出现在使用者初始时视角位置的前面
    • matrix4.translate(textLeftShift, 0, 0)textLeftShift 则是有点 RWD 概念,如果装置为宽萤幕则让文字面板往左偏移一点使得船可以在一开始不被文字遮到
  • 当然得设定 uniform 使得 texture 为刚才建立的文字 texture: u_diffuseMap: textures.text
    • 为了避免其他物件设定过的 u_diffuse,这边将之设定成黑色

render(app) 中呼叫 renderText(app, viewMatrix, programInfo);,可以看到黑底白字的说明出现:

black-bg-and-white-text-but-flipped

但是上下颠倒了,为了修正这个问题,我们在 gl.texImage2D() 输入文字 texture 之前要设定请 WebGL 把输入资料的 Y 轴颠倒:

 function createTextTexture(gl) {
   // ...

+  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);

   gl.texImage2D(
     gl.TEXTURE_2D,
     0, // level
     gl.RGBA, // internalFormat
     gl.RGBA, // format
     gl.UNSIGNED_BYTE, // type
     canvas, // data
   );
   gl.generateMipmap(gl.TEXTURE_2D);

+  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);

   // ...
 }

为了避免影响到别的 texture 的载入,用完要设定回来。加入这两行之後文字就正常罗:

black-bg-and-white-text

半透明物件

原本半透明的文字 texture 透过 3D 物件渲染到场景变成不透明的了,因为使用的 fragment shader 输出的 gl_FragColor 的第四个元素,也就是 alpha/透明度,固定是 1:

void main() {
  // ...
  gl_FragColor = vec4(
    clamp(
      diffuse * diffuseBrightness +
      u_specular * specularBrightness +
      u_emissive,
      ambient, vec3(1, 1, 1)
    ),
    1
  );
}

这使得窄板萤幕一开始文字会遮住导致看不到主角,是不是有办法可以让这个说明看板变成半透明的呢?有的,首先当然是要有一个愿意根据 texture 输出 alpha 值的 fragment shader,因为这个文字看板物件不会需要有光影效果,写一个简单的 fragment shader textFragmentShaderSource 给它用:

precision highp float;

uniform vec4 u_bgColor;
uniform sampler2D u_texture;

varying vec2 v_texcoord;

void main() {
  gl_FragColor = u_bgColor + texture2D(u_texture, v_texcoord);
}

可以看到除了 u_texture 用来输入文字 texture 之外还有 u_bgColor,可以用来输入整体的底色。与原本的 vertex shader 连结建立对应的 programInfo 并让看板物件使用:

 async function setup() {
   // ...
   const programInfo = twgl.createProgramInfo(gl, [vertexShaderSource, fragmentShaderSource]);
+  const textProgramInfo = twgl.createProgramInfo(gl, [vertexShaderSource, textFragmentShaderSource]);
   const depthProgramInfo = twgl.createProgramInfo(gl, [vertexShaderSource, depthFragmentShaderSource]);
   
   // ...
   
   return {
     gl,
-    programInfo, depthProgramInfo, oceanProgramInfo, skyboxProgramInfo,
+    programInfo, textProgramInfo, depthProgramInfo, oceanProgramInfo, skyboxProgramInfo,
     textures, framebuffers, objects,
     // ...
   }
 }
 
 function render(app) {
   const {
     gl,
     framebuffers, textures,
-    programInfo, depthProgramInfo, oceanProgramInfo, skyboxProgramInfo,
+    programInfo, textProgramInfo, depthProgramInfo, oceanProgramInfo, skyboxProgramInfo,
     state,
   } = app;
   
   // ...
   
   gl.bindFramebuffer(gl.FRAMEBUFFER, null);

   twgl.resizeCanvasToDisplaySize(gl.canvas, state.resolutionRatio);
   gl.viewport(0, 0, canvas.width, canvas.height);

   gl.useProgram(programInfo.program);
   
   renderBoat(app, viewMatrix, programInfo);
  
-  renderText(app, viewMatrix, programInfo);
+  gl.useProgram(textProgramInfo.program);
+  renderText(app, viewMatrix, textProgramInfo);

   // ...
 }
 
 function renderText(app, viewMatrix, programInfo) {
   // ...
   
   twgl.setUniforms(programInfo, {
     u_matrix: matrix4.multiply(viewMatrix, worldMatrix),
-    u_diffuse: [0, 0, 0],
-    u_diffuseMap: textures.text,
+    u_bgColor: [0, 0, 0, 0.1],
+    u_texture: textures.text,
   });
   
   // ...
 }

修改输入之 uniform,u_texture 输入文字 texture,而 u_bgColor 使得透明度为 0.1。看起来像是这样:

gray-bg-white-text

有变化,但是依然不是半透明的,因为还需要请 WebGL 启用 gl.BLEND 颜色混合功能:

 async function setup() {
   // ...

   gl.enable(gl.CULL_FACE);
   gl.enable(gl.DEPTH_TEST);
+  gl.enable(gl.BLEND);
+  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
   
   // ...
 }

事实上,gl.BLEND 启用的行为是让要画上去的颜色与画布上现有的颜色进行运算後相加,而运算的方式由 gl.blendFunc(sfactor, dfactor) 设定,

  • sfactor 表示要画上去的颜色要乘以什麽,预设值为 gl.ONE,设定成 gl.SRC_ALPHA 乘以自身之透明度
  • dfactor 表示画布上的颜色要乘以什麽,预设值为 gl.ZERO,这个显然也要修改,要不然底下的颜色就等於完全被覆盖掉

既然是『与画布上现有的颜色进行运算』,半透明的物件绘制时会需要画布上已经有绘制好其他物件,我们来看看如果按照『帆船、看板、海洋、天空』的顺序绘制会变成什麽样子:

transparent-to-boat-but-not-ocean

画看板时画布上只有帆船,看板内帆船以外的区域等於是空白画布的颜色与 u_bgColor 混合的结果,又因为深度已经写入了看板的距离,在绘制海洋时就被判定成在後面而没有画上去形成这样的现象。因此半透明物件应该要最後画上:

 function render(app) {
   // ...

   gl.bindFramebuffer(gl.FRAMEBUFFER, null);

   twgl.resizeCanvasToDisplaySize(gl.canvas, state.resolutionRatio);
   gl.viewport(0, 0, canvas.width, canvas.height);

   gl.useProgram(programInfo.program);

   renderBoat(app, viewMatrix, programInfo);
-
-  gl.useProgram(textProgramInfo.program);
-  renderText(app, viewMatrix, textProgramInfo);

   gl.useProgram(oceanProgramInfo.program);
   twgl.setUniforms(oceanProgramInfo, globalUniforms);
   renderOcean(app, viewMatrix, reflectionMatrix, oceanProgramInfo);

   gl.useProgram(skyboxProgramInfo.program);
   renderSkybox(app, projectionMatrix, inversedCameraMatrix);
+
+  gl.useProgram(textProgramInfo.program);
+  renderText(app, viewMatrix, textProgramInfo);
 }

半透明效果就完成罗:

transparent-text

不过还有一个小问题,u_bgColor 跟文字 texture 的底色都是黑色,半透明的区域颜色应该要比较深才对,怎麽会比较浅呢?如果去修改 HTML 那边的 <canvas></canvas> 给上 CSS 的背景色,就能发现是因为画布的看板区域对於 HTML 来说是半透明的,因此网页底下的颜色就透上来了:

<canvas id="canvas" style="background: green;"></canvas>

transparent-with-green

<canvas></canvas> 有一个黑色的底色:

 <body>
-  <canvas id="canvas"></canvas>
+  <canvas id="canvas" style="background: black;"></canvas>
   <!-- ... -->
 </body>

透明的文字看板就大功告成罗:

transparent-text-completed

完整的程序码在此:


<<:  更新Android Studio Arctic Fox | 2020.3.1与android X 与相关开发环境升级

>>:  [Day17]-应用模组2

前端工程日记 30日 名片设计

如图 pancode: div 设计成 各种形状 三角形。五角形 六角形 的方法 制作参考引用 ht...

电子书阅读器上的浏览器 [Day13] 自订工具列

随着开发的功能愈来愈多,工具列的空间已不足以将所有的功能都显示在上面;而且也不是每个功能都是使用者会...

Domain layer implementation

经过这麽多集的 data layer 後,我们来到 domain layer。Domain laye...

关闭核电厂的椅子

故事简述 核二厂2号机於2021/7/27清晨6时32分发生反应炉急停事件,初步调查为人为疏失 原能...

[VSCodeVim] 推荐的Vim、VSCodeVim的参考资源

推荐的Vim、VSCodeVim的参考资源 [系列文目录] 这篇文章推荐几个Vim与VSCodeVi...