Cocos Shader 基础入门(四):纹理映射
在前三章里,我们学会了用 WebGL 绘制一个三角形、变着花样替换顶点数据等。但目前为止所有绘制的内容都是基于基础图元的绘制,如果想让绘制的内容看起来更真实,就需要有更多的顶点或者足够多的颜色。比如:一个画框里的画,可以是蒙娜丽莎,可以是向日葵,此时,我们可以增加一张纹理来为物体添加更多的细节。
纹理的应用就涉及到一项很重要的技术:纹理映射。所谓纹理映射,就是将一张图片映射到一个几何图形的表面上去,比如纹理映射到矩形物体上,这个矩形看上去就像是一张图片,这张图片又可以称为纹理图像或纹理。
纹理映射
纹理映射的作用是根据纹理图像,为光栅化后的每个片元涂上适当的颜色,组成纹理图像的像素又称之为纹素(Texel),每一个纹素的颜色都可以使用 RGB 或者 RGBA 格式编码。
纹理坐标
为了能把一张纹理映射到物体上,我们需要指定物体的每个顶点各自对应纹理的哪个部分。纹理使用上更多采用的是 2D 纹理,纹理坐标在 x 和 y 轴上,范围在 0-1 之间。2D 的纹理坐标通常又称之为 uv 坐标,u 对应水平方向,也就是 x 轴,v 对应垂直方向,也就是 y 轴。如果是 3D 纹理,第三个则是 w,对应 z 轴。纹理坐标始于(0,0)点,也就是纹理左下角,终于(1,1),也就是纹理的右上角。使用纹理坐标来获取纹理颜色的方式称之为采样。每个顶点会关联着一个纹理坐标,用来表明该从纹理的哪部分采样。
纹理坐标看起来像是这样的:
0, 0, // 左下角
0, 1, // 左上角
1, 0, // 右下角
1, 1 // 右上角
];
映射原理主要是将纹理图像的顶点映射到 WebGL 坐标系统的四个顶点。
纹理环绕方式
纹理坐标的范围通常是从 (0, 0) 到 (1, 1),如果超出这个范围该怎么办呢?OpenGL 默认行为是重复这个纹理图像,但是也提供了一些其它选择:
// 可以通过 gl.texParameter[fi] 对坐标不同轴向设置(2D 纹理 st 对应 uv,3D 纹理 str 对应 uvw )
// void gl.texParameterf(target, pname, param);
// 参数请参考:https://developer.mozilla.org/zh-CN/docs/Web/API/WebGLRenderingContext/texParameter
// 由于应用条件较多,可以直接上链接了解一下,然后对应理解教程里涉及的部分即可
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);
当纹理坐标超出默认范围时,每个选项都有不同的视觉效果输出。
纹理过滤
纹理坐标不依赖分辨率,可以是任意浮点值,所以 OpenGL 知道如何将纹素映射到纹理坐标。但是,如果此时有一个小的纹理需要映射到一个很大的物体上,就可能导致多个像素都映射到同一个纹素上,相反,单个像素可能会被映射到多个纹素。纹理过滤就是为了解决不一致时纹理的采样计算问题,其中最重要的就是如下两种:
NEAREST 临近过滤(下图左):选择中心点最接近纹理坐标的那个像素,也是最简单的纹理过滤方式,效率最高。
LINEAR 线性过滤(下图右):选择中心点周围最近的 4 个纹素加权计算出来,一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。
从图中可以看出,采用临近过滤的图片有更明显的锯齿感(比如眼眶那个地方),而右边图片则更加平滑。我这里选用的图片尺寸较大,尺寸小的会更加明显。线性过滤可以产生更加真实的输出,但是如果想开发像素风格的游戏,就可以用临近过滤选项。
当对图像进行放大和缩小的时候,我们可以选择不同的过滤选项。比如:在缩小的时候采用临近过滤,获取最高效率;放大时用线性过滤,获得较好表现。纹理过滤的使用方式跟纹理环绕类似:
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
// 当进行放大时
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
将纹理应用到矩形上
接着,试着把纹理坐标关联给上一章的矩形。先将所有的顶点颜色还原成白色,下方列出了所有代码:
// ...
}
function createProgram(gl, vertexShader, fragmentShader) {
// ...
}
function main() {
const image = new Image();
// 如果是用 WebGL 中文文档上内置的运行环境编辑内容的,可以直接用网站内置的纹理图片。https://webglfundamentals.org/webgl/resources/leaves.jpg。
// 由于我这里是自定义了本地的文件,因此创建了一个本地服务器来加载图片。使用本地文件的方式在文章末尾处。
image.src = "http://192.168.55.63:8080/logo.png";
image.onload = function() {
render(image);
};
}
function render() {
const canvas = document.createElement('canvas');
document.getElementsByTagName('body')[0].appendChild(canvas);
canvas.width = 400;
canvas.height = 300;
const gl = canvas.getContext("webgl");
if (!gl) {
return;
}
const vertexShaderSource = `
attribute vec2 a_position;
// 纹理贴图 uv 坐标
attribute vec2 a_uv;
attribute vec4 a_color;
varying vec4 v_color;
varying vec2 v_uv;
// 着色器入口函数
void main() {
v_color = a_color;
v_uv = a_uv;
gl_Position = vec4(a_position, 0.0, 1.0);
}`;
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
// 让顶点的比例和图像比例一致
const ratio = (image.width / image.height) / (canvas.width / canvas.height);
const positions = [
-ratio, -1,
-ratio, 1,
ratio, -1,
ratio, 1
];
const uvs = [
0, 0, // 左下角
0, 1, // 左上角
1, 0, // 右下角
1, 1 // 右上角
];
// 在片元着色器文本处暂时屏蔽颜色带来的影响,但此处颜色值我们还是上传给顶点着色器
const colors = [
255, 0, 0, 255,
0, 255, 0, 255,
0, 0, 255, 255,
255, 127, 0, 255
];
const indices = [
0, 1, 2,
2, 1, 3
];
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
const attribOffset = (positions.length + uvs.length) * Float32Array.BYTES_PER_ELEMENT + colors.length;
const arrayBuffer = new ArrayBuffer(attribOffset);
const float32Buffer = new Float32Array(arrayBuffer);
const colorBuffer = new Uint8Array(arrayBuffer);
// 当前顶点属性结构方式是 pos + uv + color
// 按 float 32 分布 pos(2)+ uv(2) + color(1)
// 按子节分布 pos(2x4) + uv(2x4) + color(4)
let offset = 0;
let i = 0;
for (i = 0; i < positions.length; i += 2) {
float32Buffer[offset] = positions[i];
float32Buffer[offset + 1] = positions[i + 1];
offset += 5;
}
offset = 2;
for (i = 0; i < uvs.length; i += 2) {
float32Buffer[offset] = uvs[i];
float32Buffer[offset + 1] = uvs[i + 1];
offset += 5;
}
offset = 16;
for (let j = 0; j < colors.length; j += 4) {
// 2 个 position 的 float,加 4 个 unit8,2x4 + 4 = 12
// stride + offset
colorBuffer[offset] = colors[j];
colorBuffer[offset + 1] = colors[j + 1];
colorBuffer[offset + 2] = colors[j + 2];
colorBuffer[offset + 3] = colors[j + 3];
offset += 20;
}
gl.bufferData(gl.ARRAY_BUFFER, arrayBuffer, gl.STATIC_DRAW);
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);
const fragmentShaderSource = `
precision mediump float;
varying vec2 v_uv;
varying vec4 v_color;
// GLSL 有一个供纹理对象使用的内建数据类型,叫做采样器(Sampler),它以纹理类型作为后缀
// 比如此处使用的是 2D 纹理,类型就定义为 sampler2D
uniform sampler2D u_image;
// 着色器入口函数
void main() {
// 使用 GLSL 内建函数 texture2D 采样纹理,它第一个参数是纹理采样器,第二个参数是对应的纹理坐标
// 函数会使用之前设置的纹理参数对相应的颜色值进行采样,这个片段着色器的输出就是纹理的(插值)纹理坐标上的(过滤后的)颜色。
gl_FragColor = texture2D(u_image, v_uv);
}`;
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
const program = createProgram(gl, vertexShader, fragmentShader);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor(0, 0, 0, 255);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
const positionAttributeLocation = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(positionAttributeLocation);
const uvAttributeLocation = gl.getAttribLocation(program, "a_uv");
gl.enableVertexAttribArray(uvAttributeLocation);
const colorAttributeLocation = gl.getAttribLocation(program, "a_color");
gl.enableVertexAttribArray(colorAttributeLocation);
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, 20, 0);
// 新增顶点属性纹理坐标,这里大家应该都很清楚了,就不再多说了
gl.vertexAttribPointer(uvAttributeLocation, 2, gl.FLOAT, false, 20, 8);
gl.vertexAttribPointer(colorAttributeLocation, 4, gl.UNSIGNED_BYTE, true, 20, 16);
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
// 设置纹理的环绕方式
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);
// 设置纹理的过滤方式
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
// gl.texImage2D(target, level, internalformat, format, type, HTMLImageElement? pixels);
// 此接口主要为了指定二维纹理图像,图像的来源有多种,可以直接采用 HTMLCanvasElement、HTMLImageElement 或者 base64。此处选用最基础的 HTMLImageElement 讲解。
// 关于参数的详细内容请参考:https://developer.mozilla.org/zh-CN/docs/Web/API/WebGLRenderingContext/texImage2D
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);
}
最终,我们会在屏幕上看到这样的成像:
图片上下颠倒了,这是因为除了纹理坐标之外,图片自身也是有坐标系的,图片的坐标原点始于左上角,终于右下角,取值范围也是 0-1。把一张图片加载到纹理中,图片数据就会从图片坐标系到了纹理坐标系,此时图片就已经出现了上下倒置,所以我们需要一个 flipY 的操作,在渲染的时候将上下再进行一次倒置。
// 翻转图片
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
可能会有些同学有疑问,为什么 sampler2D 是个 uniform,但是却不用 gl.uniform 相关来赋值呢?因为在 OpenGL 中,会给纹理分配一个默认的纹理位置,称之为纹理单元。默认激活的纹理单元是 0,因此,我之前没有执行任何位置值分配,纹理贴图会自动绑定到默认纹理单元上。当然,我们也可以通过 gl.uniform 来给片段着色器设置多个纹理,只需要激活对应的纹理单元。通用设备支持 8 个纹理单元,现代中高端设备支持会更多,这个只能具体机型具体分析,一般限制在 8 个即可,它们的编号分别是 gl.TEXTURE0 - 8。通过这种编号方式,我们在循环纹理单元的时候会很方便,不过这个都是后话了。
接下来,我们尝试多加一个纹理。在原有代码上进行如下改造:
function main() {
// 新增加一张纹理贴图
const images = ["http://192.168.55.63:8080/logo.png", "http://192.168.55.63:8080/close-icon.png"];
const dataList = [];
let index = 0;
for (let i = 0; i < 2; i++) {
const image = new Image();
image.src = images[i];
dataList.push(image);
image.onload = function () {
index++;
if (index >= images.length) {
render(dataList);
}
};
}
}
function render(dataList) {
// ...
// 重新定义顶点位置
const ratio = 0.5;
const positions = [
-ratio, -1,
-ratio, 1,
ratio, -1,
ratio, 1
];
// ...
// 修改片元着色器文本
const fragmentShaderSource = `
precision mediump float;
varying vec2 v_uv;
varying vec4 v_color;
// 新增一个纹理
uniform sampler2D u_image0;
uniform sampler2D u_image1;
// 着色器入口函数
void main() {
vec4 tex1 = texture2D(u_image0, v_uv);
vec4 tex2 = texture2D(u_image1, v_uv);
// 将纹理色值相乘
// rgb 和黑色相乘都为黑色(黑色 rgb 每分量都是 0),和白色相乘,都为原色(白色 rbg 每分量都是 1)
gl_FragColor = tex1 * tex2;
}`;
// ...
// 判断有纹理才设置翻转
if(dataList.length > 0){
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
}
for (let j = 0; j < dataList.length; j++) {
const data = dataList[j];
const samplerName = `u_image${j}`;
const u_image = gl.getUniformLocation(program, samplerName);
// 设置每个纹理的位置值
gl.uniform1i(u_image, j);
const texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0 + j);
gl.bindTexture(gl.TEXTURE_2D, texture);
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);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, data);
}
}
原图和渲染后的图片对比:
到这里为止,相信大家应该了解了纹理映射是怎么回事,接下来,我们再来看几个使用案例。
更多案例
这里展示的几个案例,还是按照一个纹理呈现。
不同顶点色应用到纹理
如果你跟着学到了这里,应该可以轻松实现了吧!
// 此处展示出部分应用代码
const colors = [
255, 0, 0, 255,
0, 255, 0, 255,
0, 0, 255, 255,
255, 127, 0, 255
];
const fragmentShaderSource = `
precision mediump float;
varying vec2 v_uv;
varying vec4 v_color;
uniform sampler2D u_image;
void main() {
vec4 tex1 = texture2D(u_image, v_uv);
gl_FragColor = tex1 * v_color;
}`;
再增加一点细节,就有一种镭射卡的感觉了。
改变最终输出的 RGB 顺序
const fragmentShaderSource = `
precision mediump float;
varying vec2 v_uv;
varying vec4 v_color;
uniform sampler2D u_image;
void main() {
vec4 tex1 = texture2D(u_image, v_uv).bgra;
gl_FragColor = tex1;
}`;
这个原理其实也就是将原来通道的颜色替换成另外一种颜色。
网上有很多纹理的应用实例,大家都可以尝试着去改造一下。
其他
为什么在 GLSL 中变量的前缀都是 a_, u_ 或 v_ ?
这是一个命名约定,不是强制的,只是为了更清晰的知道值应该从哪里来,比如:a_ 就是指向顶点输入属性 attribute,代表数据是从顶点缓冲中来;u_ 就是全局变量 uniform,可以直接对着色器设置;v_ 代表可变量 varying,是从顶点着色器的顶点中插值而来。
本地服务器搭建
由于本次教程的 WebGL 测试内容我都放在自定义文件夹里。因此,需要一个服务器去运行 HTML 文件。文件夹内容如下:
接着,在文件夹下安装 npm 库 http-server:
npm install http-server -g
// 安装完成之后执行
http-server
// 此时控制台会出现类似如下:
Available on:
http://127.0.0.1:8080
http://192.168.55.63:8080
选择其中一个地址使用即可,但是不可混用,不然可能出现跨域问题。比如:我用 “http://192.168.55.45:8080” 打开项目,那么图片加载也用这个地址。
最后,在浏览器上输入该地址,点击 html 文件即可测试。
注意:如果提示没有 npm 命名,可能是因为你没有安装过 Node.js 任何版本,请安装 Node.js。如果切换了网络,因此 IP 地址已经改变,记得也要重新执行 http-server,重新生成本地服务器。
内容参考:
1. WebGL 基础:
https://webglfundamentals.org/webgl/lessons/zh_cn/webgl-fundamentals.html
2. WebGL API 对照表:
https://www.khronos.org/files/webgl/webgl-reference-card-1_0.pdf
3. OpenGL 中文文档:
https://learnopengl-cn.github.io/01%20Getting%20started/04%20Hello%20Triangle/
4. OpenGL 纹理旋转及翻转问题详解:
https://juejin.cn/post/6854573205378727949
往期精彩