在 WebGL 取用、显示图片 - Textures

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

有在玩游戏的读着们在讨论一款 3D 游戏的时候,可能有提到游戏内的『3D 贴图』,游戏 3D 物件表面常常不会是纯色或是渐层单调的样子,而是有一张图片贴在这个物件表面的感觉,所以才叫做『3D 贴图』吧,而且也可以用在图案重复的『材质』显示上,因此在英文叫做 texture。虽然现在还完全没有进入 3D 的部份,但是 3D 贴图/texture 追根究底得有个方法在 WebGL 里面取用、显示图片,本篇抽离 3D 的部份,来介绍在 WebGL 取用、显示图片的方式

02-texture-2d.html / 02-texture-2d.js

本篇开始将使用新的 .html 作为开始,起始点完整程序码可以在这边找到:github.com/pastleo/webgl-ironman/commit/75179fb,笔者将 createShader, createProgram 移动到工具箱 lib/utils.js,里面有 loadImage 用来下载并回传 Image 元素(注意,是 async function),并且 positionDay 5 相同使用 pixel 座标定位,看起来像是这样:

02-texture-2d-start

这一个灰色的正方形是由两个三角形组成的,读者可以在 02-texture-2d.jsgl.bufferData() 中看到每个顶点後面有一个注解字母,其对应了下面这张示意图:

square-vertices

接下来以此为起点,让灰色方形区域显示图片

建立 WebGL texture

建立之前,把来源图片下载好,直接呼叫 loadImage 并传入图片网址,因为牵扯到非同步,这边得用 await(也是因此得写在 async function main() 内):

const image = await loadImage('https://i.imgur.com/ISdY40yh.jpg');

这张图片是笔者的大头贴:

pastleo

接着建立、bind(对准)并设定 texture:

const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(
  gl.TEXTURE_2D,
  0, // level
  gl.RGB, // internalFormat
  gl.RGB, // format
  gl.UNSIGNED_BYTE, // type
  image, // data
);

可以发现 gl.createTexture() / gl.bindTexture() 这个组合与 gl.createBuffer() / gl.bindBuffer() 这个组合神似,建立并且把 gl.TEXTURE_2D 目标对准 texture;接下来设定 texture 资料 gl.texImage2D():

  • level: 对於一个 texture 其实有许多缩放等级,与接下来的 gl.generateMipmap 有一定的关系,不过这边通常是填 0 表示输入的是原始尺寸/最大张的图
  • internalFormat, format, type: 根据 mdn 文件format 在 WebGL1 必须与 internalFormat 相同,从文件中 internalformat 下方的表格可以看到有哪些选项可以填,显然来源图片有 RGB 三个 channel,想当然尔 format 选 RGB,而 type 没有特别的需求选择 UNSIGNED_BYTE

最後 data 直接把 image 元素给进去,图片就以 RGB 的格式输入到 GPU 内 texture 的 level: 0 位置上

读者可能对 level 的部份很纳闷,可以想像一下,在 3D 的世界中镜头可能距离贴图很遥远,显示时为了效率没办法当下做完整图片的缩放,因此会事先把各个尺寸的缩图做好放在记忆体里,这样的东西叫做 mipmap,而 level 表示缩放的等级,0 表示没有缩放的版本。所以开发者得自己把各个缩放尺寸做好分别输入吗?幸好 WebGL 有内建方法一行对着目前的 texture 产生所有尺寸:

gl.generateMipmap(gl.TEXTURE_2D);

如何在 shader 中使用 texture

回想 fragment shader 的运作方式:在每个 pixel 运算其颜色。那麽如果要显示 texture,就会变成『在每个 pixel 运算时从 texture 图片上的某个位置取出其颜色来输出』。在 GLSL 中可以透过 uniform 传输一种叫做 Sampler2D 的资料型别:

uniform sampler2D u_texture;

把这个 uniform 变数叫做 u_texture,其实就是 texture。接着 GLSL 的内建 function texture2D() 可以进行上面所说的『从 texture 图片上的某个位置取出其颜色』:

gl_FragColor = texture2D(u_texture, v_texcoord);

v_texcoord 即为『某个位置』,既然是 sampler2Dv_texcoord 型别必须是 vec2 表示在 texture 图片上的 (x, y) 座标,并且 (0.0, 0.0) 为图片左上角, (1.0, 1.0) 为图片右下角。脑筋快的应该已经想到,v_texcoord 是一个 varying,因为每个顶点之间所要取用的 texture 图片座标是连续、平滑的。最後完整的 fragment shader 长这样:

precision mediump float;

varying vec2 v_texcoord;

uniform sampler2D u_texture;

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

Varying v_texcoord

Day 5 类似,既然 fragment shader 需要 varying,因此得在 vertex shader 提供 varying,vertex shader 又需要从 attribute 取得 texture 各个顶点需要取用的座标,对 vertex shader 加上这几行:

 attribute vec2 a_position;
+attribute vec2 a_texcoord;
  
 uniform vec2 u_resolution;
  
+varying vec2 v_texcoord;
+
 void main() {
   gl_Position = vec4(
     a_position / u_resolution * vec2(2, -2) + vec2(-1, 1),
     0, 1
   );
+  v_texcoord = a_texcoord;
 }

取得 a_texcorrd attribute 位置、并设定 buffer, vertex attribute array:

const texcoordAttributeLocation = gl.getAttribLocation(program, 'a_texcoord');

// ...

// a_texcoord
const texcoordBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer);

gl.enableVertexAttribArray(texcoordAttributeLocation);
gl.vertexAttribPointer(
  texcoordAttributeLocation,
  2, // size
  gl.FLOAT, // type
  false, // normalize
  0, // stride
  0, // offset
);

gl.bufferData(
  gl.ARRAY_BUFFER,
  new Float32Array([
    0, 0, // A
    1, 0, // B
    1, 1, // C

    0, 0, // D
    1, 1, // E
    0, 1, // F
  ]),
  gl.STATIC_DRAW,
);

因为 texture (0.0, 0.0) 为图片左上角, (1.0, 1.0) 为图片右下角,在 gl.bufferData() 对於每个顶点的填入的 texcoord 示意图如下:

position-texcoord

提供 texture 给 shader 使用

整张 texture 应该是个巨大的阵列资料,但是与 array buffer 不同,texture 必须提供随机存取(random access),意思是说 fragment shader 不论在哪个 pixel 都可以取用 texture 任意位置的资料;texture 又是用 uniform 类似指标的方式提供给 shader 使用,可能 texture 在 GPU 上有特别的硬体做处理

首先一样取得 uniform 位置:

const textureUniformLocation = gl.getUniformLocation(program, 'u_texture');

然後设定把 texture 启用在一个『通道』,并把这个通道的编号传入 uniform:

// after gl.useProgram()...

const textureUnit = 0;
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.activeTexture(gl.TEXTURE0 + textureUnit);
gl.uniform1i(textureUniformLocation, textureUnit);
  • textureUnit 为通道的编号,设定为 0 使用第一个通道
  • gl.bindTexture 把目标指向建立好的 texture,如果有其他 texture 导致目标更换时,这边要把目标设定正确,虽然本篇只有一个 texture 就是了
  • gl.activeTexture() 启用通道并把目标 texture 设定到通道上,这边还有神奇的 gl.TEXTURE0 + textureUnit 写法;读者可以尝试在 Console 输入 gl.TEXTURE1 - gl.TEXTURE0 (1),或是 gl.TEXTURE5 - gl.TEXTURE2 (3),就可以知道为什麽可以用 + 共用 textureUnit 指定通道了
  • Day 4 介绍 uniform 提到对於每种资料型别都有一个传入 function,gl.uniform1i 传的是 1 个整数,把通道的编号传入,在 fragment shader 中就会直接被反应成 sampler2D

一切顺利的话,就可以看到图片出现在 canvas 里头,fragment shader 成功地『在每个 pixel 运算时从 texture 图片上的某个位置取出其颜色来输出』:

texture-result

读者如果有兴趣,可以修改 texcoord 的数字感受一下 texture2D(),像是把 C 点 texcoord 改成 (0.8, 0.8) 就变成这样:

texture-0.8

本篇的完整程序码可以在这边找到:

但是关於 texture 其实还有许多细节,待下篇再来继续讨论


<<:  30天轻松学会unity自制游戏-让Player动起来

>>:  [13th-铁人赛]Day 1:Modern CSS 超详细新手攻略 - 简介

Day 18 Sort

演算法在程序设计中扮演重要的角色,而演算法和时间复杂度有很大的关联, 时间复杂度本意为程序执行的时间...

Backtrader - 策略收益

以下内容皆参考 Backtrader 官网 昨天介绍了 backtrader 如何去执行一个策略,今...

【後转前要多久】# Day24 JS - JavaScript 物件、函式

物件内可以放function 学生考了几分 var exam = {} console.log(ex...

Day07 React之CSS样式设定

在React中加入CSS样式分爲3种方式: 1.External css (外部样式) 外部样式是使...

Dungeon Mizarka 029

等待UI开发者的回覆之际,算是有空闲的时间可以展开实际的怪物调整。有监於Invenotry系统的复杂...