查看原文
其他

Cocos Shader 基础入门(四):纹理映射

放空 COCOS 2022-06-10

在前三章里,我们学会了用 WebGL 绘制一个三角形变着花样替换顶点数据等。但目前为止所有绘制的内容都是基于基础图元的绘制,如果想让绘制的内容看起来更真实,就需要有更多的顶点或者足够多的颜色。比如:一个画框里的画,可以是蒙娜丽莎,可以是向日葵,此时,我们可以增加一张纹理来为物体添加更多的细节。


纹理的应用就涉及到一项很重要的技术:纹理映射所谓纹理映射,就是将一张图片映射到一个几何图形的表面上去,比如纹理映射到矩形物体上,这个矩形看上去就像是一张图片,这张图片又可以称为纹理图像或纹理


纹理映射


纹理映射的作用是根据纹理图像,为光栅化后的每个片元涂上适当的颜色,组成纹理图像的像素又称之为纹素(Texel),每一个纹素的颜色都可以使用 RGB 或者 RGBA 格式编码。


纹理坐标


为了能把一张纹理映射到物体上,我们需要指定物体的每个顶点各自对应纹理的哪个部分。纹理使用上更多采用的是 2D 纹理,纹理坐标在 x 和 y 轴上,范围在 0-1 之间。2D 的纹理坐标通常又称之为 uv 坐标,u 对应水平方向,也就是 x 轴,v 对应垂直方向,也就是 y 轴。如果是 3D 纹理,第三个则是 w,对应 z 轴。纹理坐标始于(0,0)点,也就是纹理左下角,终于(1,1),也就是纹理的右上角。使用纹理坐标来获取纹理颜色的方式称之为采样。每个顶点会关联着一个纹理坐标,用来表明该从纹理的哪部分采样。



纹理坐标看起来像是这样的:

const uvs = [
    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 createShader(gl, type, source) {
    // ...
}

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


往期精彩

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存