«

WebGL 的入门教程,实现一个旋转的三维立方体

时间:2025-3-25 12:27     作者:独元殇     分类: 前端技术


[toc]

前言 1

昨天,我在电脑里发现了以前学习 WebGL 的代码和笔记,然后我就整理了一下,写成了一个教程。写完后,发现字数还不少。如果大家对浏览器显示三维的玩意儿感兴趣,可以读一下,能当教程,也能当简易的文档查阅。WebGL 是浏览器的三维引擎,但它很原始和底层,就像你买了个计算器,发现这个计算器只有一个加号键一样..... 什么都需要自己去手写。

前言 2

WebGL 是一个在浏览器里使用的高效渲染二维和三维图形的 javascript API,于 2006 年起源,该技术基于 OpenGL。我们在网上看到的很多炫酷的三维效果,都是使用 WebGL 实现的。基于它也有相当多的 3D 库,比较著名的有 three.js 、babylon.js ...

我们即便在项目中还是使用成熟的框架,但最终我们还是不免有一天来探索一下 Webgl 的原理,本教程特别适合我们去入门学习。

它的代码由 javascript 和 GLSL(OpenGL 着色语言)组成,后者和 C 语言很接近,不过结构很简单,程序员看到都会感觉很熟悉。

WebGL 的代码,除了是使用 javascript 写的之外,其余的基本和前端就没关系了,是一个比较专业的领域。废话不多说,下面几小节内容,将会带大家简单地入门 WebGL。

完整的示例代码,可见 https://github.com/kohunglee/webgl_learn

当然,为了方便学习,下文中每小节的末端也会附上本节程序的【完整代码】 的折叠形式,方便调试。

第一节 绘制出了一个点

效果演示

这是一个最小可运行的 webGL 代码。效果如图:

img

    <canvas class="canvas" width="500" height="500">

    </canvas>
    <!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        void main(){
            gl_PointSize = 10.0;
            gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
        }
    </script>

    <!-- 片元着色器--><script id="fragment-shader" type="x-shader/x-fragment">
        void main(){
            gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
        }
    </script>
    <script>
        const canvas = document.querySelector('.canvas');
        const gl = canvas.getContext('webgl');

        // 创建顶点着色器和片元着色器
        const vertexShader = gl.createShader(gl.VERTEX_SHADER);
        const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);

        // 创建着色器源码
        const vertexSource = document.getElementById('vertex-shader').innerText;
        const fragmentSource = document.getElementById('fragment-shader').innerText;

        //绑定着色器源码
        gl.shaderSource(vertexShader, vertexSource);
        gl.shaderSource(fragmentShader, fragmentSource);

        // 编译着色器源码
        gl.compileShader(vertexShader);
        gl.compileShader(fragmentShader);

        // 创建程序对象
        const program = gl.createProgram();
        gl.attachShader(program, vertexShader);
        gl.attachShader(program, fragmentShader);

        // 链接
        gl.linkProgram(program);
        gl.useProgram(program);

        // 绘制
        gl.drawArrays(gl.POINTS, 0, 1);
    </script>

逐行解释

首先是第一行的 canvas 元素,我们发现有 width="500" height="500",画布大小应该在这个地方或 js 里定义,而不要在 CSS 里面定义,否则画面会拉深。

/* 注意,不要在 CSS 里面定义画布大小,下面这种写法是错的! */
.canvas { 
    width:500px;
    height:500px;
}

之后是两行着色器源码。一个叫顶点着色器,一个叫片元着色器。它们可以写在 js 代码里,在这里方便演示,也为了显得更规范,我们写在了 html 的 script 标签里。

先看顶点着色器:

main 是主函数,里面代码的解析如下:

gl_PointSize = 10.0;  // 点 大小是 10
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);  // 点的坐标位置是(X: 0 ,Y:0, Z:0),是一个 3D 点。

gl_Position = vec4(0.0, 0.0, 0.0, 1.0); 中,坐标为 (x, y, z, w) 类型。其中 w 是指的该坐标是否参与透视,如果是 1.0 则参与,这是个 3D 点(近大远小),如果是 0.0 则不参与,则是一个普通的向量。

在 webgl 的画布里,坐标和以往的 <canvas> 之 2D 坐标是不一样的。WebGL 的坐标如下,0 点是在画布正中央:

img

而以往 <canvas> 之 2D 绘图,我们的坐标是:

img

片元着色器:

gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);  // 像素的点的 RGBA 值是(1.0, 0.0, 0.0, 1.0),红色

RGBA 每个值的含义:前三个分量分别对应红(Red)、绿(Green)、蓝(Blue),范围通常是 0.0(无强度)到 1.0(最大强度)。第四个分量是 ‌Alpha‌,表示透明度:

向量类型

vec4 是向量类型,在 glsl 里,常用的向量类型有 vec2vec3vec4

之后是 js,第 19 行的 const gl = canvas.getContext('webgl'); 中,一定要在 getContext() 函数参数里写 webgl。这个对象的命名,我们一般习惯使用 gl

第 18、19 行,不多说了。而 26、27 行,我们使用 innerText 将两个着色器的源码传进 js 里。(其实在这里,我们以 js 字符串的形式将 glsl 代码写入也可以。)

webgl 的绘制过程,可以简单总结为:绑定着色器源码并编译、创建对象并链接、绘制。

这几个都是固定的 API 写法,如代码里的注释的那样,没太多要讲的。我们记住即可,写完这几个步骤就可以输出一个红点了。后面的代码会在此基础上添加很多东西,会显得很复杂。大家要敲几遍熟悉本节的这几个固定的内容。

第二节 动态传递点数据

我们的程序,不可能只绘制一个固定的点,我们要有很多动态内容。两个【着色器】只是定义一个固定的渲染模式,我们不能写死,所以我们需要在 js 里写“活”的东西,让【着色器】来接收并渲染。

我们本节,要将点的坐标,放到 js 里去定义,那么我们就需要做以下两个动作:

  1. js 可把点的坐标数据传入顶点着色器。
  2. 顶点着色器,可接收 js 的点的数据。

首先是第一点。我们在代码中【链接】和【绘制】中间里添加如下代码:

// 动态传递点数据
const posLocation = gl.getAttribLocation(program, 'aPos');
const posLocation1 = gl.getAttribLocation(program, 'aPos1');
const offset = [0.5, 0.5];
const offset1 = [0.1, 0.3];
gl.vertexAttrib2f(posLocation,...offset);
gl.vertexAttrib2f(posLocation,...offset1);

然后是第二点。我们修改顶点着色器的源码:

/* 
    attribute :用于顶点着色器,从顶点缓冲区传入的变量
*/
attribute vec2 aPos;
attribute vec2 aPos1;
void main(){
    gl_PointSize = 10.0;
    vec2 newPos = aPos + aPos1;
    gl_Position = vec4(newPos, 0.0, 1.0);
}

aPosaPos1 两个向量,就可以这样从 js 传递到 glsl 里,

为了方便演示向量是可以添加的,所以,代码这里特异定义了两组坐标,以顺便将“向量可相加”这个知识点展示出来。

其中核心的方法是 vertexAttrib2f(),它可以直接设置顶点 2D 属性值,它的参数如下:

gl.vertexAttrib2f(index, x, y);

在 javascript ES6 中,... 是展开操作符,vertexAttrib2f(posLocation, ...[0.5, 0.5]) 等同于 vertexAttrib2f(posLocation, 0.5, 0.5)

变量修饰符

attribute 是一个变量修饰符,被它定义的变量,可以在 js 里被赋值。除它之外,还有两个常用的变量修饰符。

attribute 修饰后的 aPosaPos1 可以在 main() 里接收,进行四者运算。

vec2 newPos = aPos + aPos1;
// 等于
vec2 newPos = vec2(0.5, 0.5) + vec2(0.1, 0.3)
// 等于
vec2 newPos = vec2(0.5 + 0.1, 0.5 + 0.3)
// 等于
vec2 newPos = vec2(0.6, 0.8)

而,vec2() 向量也是可以直接进入 vec4() 向量里面运算的。

vec4(newPos, 0.0, 1.0)
// 等于
vec4(vec2(0.6, 0.8), 0.0, 1.0)
// 等于
vec4(0.6, 0.8, 0.0, 1.0)

然后我们的点的新坐标便是(X:0.6,Y:0.8,Z:0.0),比上一节向右上偏移了点儿,效果如下:

img

完整代码

本节的完整代码如下:

    <!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        /* 
            attribute :用于顶点着色器,从顶点缓冲区传入的变量
            也就是 `gl.getAttribLocation` 使用
        */

        attribute vec2 aPos;
        attribute vec2 aPos1;
        void main(){
            gl_PointSize = 10.0;
            vec2 newPos = aPos + aPos1;
            gl_Position = vec4(newPos, 0.0, 1.0);
        }
    </script>

    <!-- 片元着色器--><script id="fragment-shader" type="x-shader/x-fragment">
        void main(){
            gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);  // 红色
        }
    </script>
    <script>
        const canvas = document.querySelector('.canvas');
        const gl = canvas.getContext('webgl');

        // 创建顶点着色器和片元着色器
        const vertexShader = gl.createShader(gl.VERTEX_SHADER);
        const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);

        // 创建着色器源码
        const vertexSource = document.getElementById('vertex-shader').innerText;
        const fragmentSource = document.getElementById('fragment-shader').innerText;

        //绑定着色器源码
        gl.shaderSource(vertexShader, vertexSource);
        gl.shaderSource(fragmentShader, fragmentSource);

        // 编译着色器源码
        gl.compileShader(vertexShader);
        gl.compileShader(fragmentShader);

        // 创建程序对象
        const program = gl.createProgram();
        gl.attachShader(program, vertexShader);
        gl.attachShader(program, fragmentShader);

        // 链接
        gl.linkProgram(program);
        gl.useProgram(program);

        // 动态传递点数据
        const posLocation = gl.getAttribLocation(program, 'aPos');
        const posLocation1 = gl.getAttribLocation(program, 'aPos1');
        const offset = [0.5, 0.5];
        const offset1 = [0.1, 0.3];
        gl.vertexAttrib2f(posLocation,...offset);
        gl.vertexAttrib2f(posLocation,...offset1);

        // 绘制
        gl.drawArrays(gl.POINTS, 0, 1);
    </script>

第三节 缓冲区和画线

这一节要复杂一点儿。我们要画线了!

我们先把片元着色器的内容,改成如下这种看起来比较标准的样子:

<!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec2 aPosition;
        void main(){
            gl_Position = vec4(aPosition, 0.0, 1.0);
        }
</script>

然后,删去上节在 JS 里添加的内容,在顶点缓冲区的【链接】下添加如下内容:

const vertices = new Float32Array([  // 一个类型化数组,定义画线的坐标,32 位浮点数,每个元素占 4 字节
    -0.5, -0.5,  // 第一个点的 x & y 坐标
    0.5, 0.5,  // 第二个点的 x & y 坐标
]);

// 将坐标绑定到缓冲区
const buffer = gl.createBuffer();  // 创建缓冲区对象
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);  // 将 buffer 缓冲区对象绑定到 ARRAY_BUFFER
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);  // 将 vertices(线的坐标)数据储存到 ARRAY_BUFFER

const posLocation = gl.getAttribLocation(program, 'aPosition');

// 告诉解析器,怎么解析我们缓冲区里面的数据
gl.vertexAttribPointer(
        posLocation,  // 属性
        2,  // 每个顶点属性,使用几个元素,(这里是 x,y ,两个)
        gl.FLOAT,  // 数据类型,浮点型
        false, // 数据是否标准化(这里是 不标准)
        2 * Float32Array.BYTES_PER_ELEMENT,  // 跨度
        0  // 从哪里开始
    );
gl.enableVertexAttribArray(posLocation);  // 激活顶点属性数组

// 绘制
gl.drawArrays(gl.LINES, 0, 2);

类型化数组

首先是 Float32Array 这个特殊的数组。我们平时在 js 里使用的 Array 数组和这个不一样。这是【类型化数组】:

正因为它高效,所以我们在 webGL 里广泛使用 类型化数组。

以下是常见的 类型化数组:

Int8Array : 8位有符号整数,每个元素占 1 字节。
Uint8Array : 8 位无符号整数,每个元素占 1 字节。
Uint8clampedArray : 8 位无符号整数(固定值,溢出时值被截断),每个元素占 1 字节。
Int16Array : 16 位有符号整数,每个元素占 2 字节。
Uint16Array : 16 位无符号整数,每个元素占 2 字节。
Int32Array : 32 位有符号整数,每个元素占 4 字节。
Uint32Array : 32 位无符号整数,每个元素占 4 字节。
Float32Array : 32 位 IEEE754 浮点数,每个元素占 4 字节。
Float64Array : 64 位 IEEE754 浮点数,每个元素占 8 字节。

我们在这里,使用类型化数组,定义了我们要画的线的两个点的坐标。

由于我们传的是好几个点的顶点数据,所以我们就不使用上节的 vertexAttrib2f,而是使用 vertexAttribPointer。它会帮我们把很多点,解析好。那么它是怎么解析的呢?就是这样:

gl.vertexAttribPointer(
        posLocation,  // 属性
        2,  // 每个顶点属性,使用几个元素,(这里是 x,y ,两个)
        gl.FLOAT,  // 数据类型,浮点型
        false, // 数据是否标准化(这里是 不标准)
        2 * Float32Array.BYTES_PER_ELEMENT,  // 跨度
        0  // 从哪里开始
    );

意思很明确,告诉解析器,我们的顶点数据(名为 vertices 的那个类型化数组),是浮点型的,每个点是两个元素,从 0 位开始解析,每次跨越 2 4 个字节数(`2 Float32Array.BYTES_PER_ELEMENT`)。

其中 Float32Array.BYTES_PER_ELEMENT 是一个常量,代表每个元素是 4 个字节。将这种代码写入参数里,不算很智能,但也确实意思明确。

之后是例行的一个激活(enableVertexAttribArray(posLocation)),没什么要说的,反正写上就行。然后是绘制了。

绘制的方式

webGL 不仅可以画点,也可以画线,现在来细讲一下 webGL 里的几种绘制方式:

gl.drawArrays(mode, first, count);

mode 有很多常量:

在本节,我们选择的是 gL.LINES 画线。最终的效果如下:

img

完整代码

本节的完整代码:

    <canvas class="canvas" width="500" height="500"></canvas>

    <!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec2 aPosition;
        void main(){
            gl_Position = vec4(aPosition, 0.0, 1.0);
        }
    </script>

    <!-- 片元着色器--><script id="fragment-shader" type="x-shader/x-fragment">

        void main(){
            gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);  // 红色
        }
    </script>
    <script>
        const canvas = document.querySelector('.canvas');
        const gl = canvas.getContext('webgl');

        // 创建顶点着色器和片元着色器
        const vertexShader = gl.createShader(gl.VERTEX_SHADER);
        const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);

        // 创建着色器源码
        const vertexSource = document.getElementById('vertex-shader').innerText;
        const fragmentSource = document.getElementById('fragment-shader').innerText;

        //绑定着色器源码
        gl.shaderSource(vertexShader, vertexSource);
        gl.shaderSource(fragmentShader, fragmentSource);

        // 编译着色器源码
        gl.compileShader(vertexShader);
        gl.compileShader(fragmentShader);

        // 创建程序对象
        const program = gl.createProgram();
        gl.attachShader(program, vertexShader);
        gl.attachShader(program, fragmentShader);

        // 链接
        gl.linkProgram(program);
        gl.useProgram(program);

        const vertices = new Float32Array([
            -0.5,
            -0.5,  // 第一个点的 x & y 坐标
            0.5,
            0.5,  // 第二个点的 x & y 坐标
        ]);
        const buffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

        const posLocation = gl.getAttribLocation(program, 'aPosition');

        // 告诉解析器,怎么解析我们缓冲区里面的数据
        gl.vertexAttribPointer(posLocation, 2, gl.FLOAT, false, 2 * Float32Array.BYTES_PER_ELEMENT, 0);

        gl.enableVertexAttribArray(posLocation);  // 启用顶点属性数组

        // 绘制
        gl.drawArrays(gl.LINES, 0, 2);
    </script>

第四节 彩色线段

上面,我们知道了【顶点着色器】怎么和 js 互动,那么现在我们再学一下【片元着色器】。可能和我们想象的略有不同。

首先,我们还是和画点一样,做两件事情:

  1. js 可把点的颜色,传入顶点着色器
  2. 顶点着色器接收 js 的点的颜色数据,然后片元着色器里再在【顶点着色器】的基础上接收颜色数据(使用 varying)。

首先第一点,在 js 的顶点缓冲区,添加如下内容:

const colors = new Float32Array([
    0.0, 0.0, 0.0, 1.0,  // 第一个点的 RGBA 颜色
    1.0, 0.0, 0.0, 0.0,  // 第二个点的 RGBA 颜色
]);
const colorLocation = gl.getAttribLocation(program, 'aColor');  // 获取属性
const colorBuffer = gl.createBuffer();  // 创建缓冲区对象
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);  // 缓冲区被绑定
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);  // 将颜色储存到缓冲区
gl.vertexAttribPointer(colorLocation, 4, gl.FLOAT, false, 4 * Float32Array.BYTES_PER_ELEMENT, 0);  // 告诉解析器,如何解析我们的数据
gl.enableVertexAttribArray(colorLocation);  // 激活

下面是着色器,可以看到,和上面的画线,操作一模一样。但是在 GLSL 里就不一样了:

<!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec2 aPosition;
        attribute vec4 aColor;
        varying vec4 vColor;
        void main(){
            gl_Position = vec4(aPosition, 0.0, 1.0);
            vColor = aColor;
        }
    </script>

    <!-- 片元着色器--><script id="fragment-shader" type="x-shader/x-fragment">
        precision highp float;  // 声明精度
        varying vec4 vColor;
        void main(){
            gl_FragColor = vColor;
        }
    </script>

我们没有在【片元着色器】里直接写 attribute 里,因为传递的值只能先到【顶点着色器】。另外,【顶点着色器】的 vColor 可以被传递到【片元着色器】。

综上段所述,颜色只能这样二次传递,才能到达【顶点着色器】。因为我们上文说过,varying: 用于在顶点着色器和片段着色器之间传递插值数据。

colors --> aColor --> vColor

精度修饰符

precision 是一个修饰符,用于声明变量的默认修饰精度,GLSL 支持三种精度:

最终的效果如下,一个漂亮的渐变线条:

img

完整代码

完整代码如下:

    <canvas class="canvas" width="500" height="500"></canvas>

    <!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec2 aPosition;
        attribute vec4 aColor;
        varying vec4 vColor;
        void main(){
            gl_Position = vec4(aPosition, 0.0, 1.0);
            vColor = aColor;
        }
    </script>

    <!-- 片元着色器--><script id="fragment-shader" type="x-shader/x-fragment">
        precision highp float;  // 声明精度
        varying vec4 vColor;
        void main(){
            gl_FragColor = vColor;  // 红色
        }
    </script>
    <script>
        const canvas = document.querySelector('.canvas');
        const gl = canvas.getContext('webgl');

        // 创建顶点着色器和片元着色器
        const vertexShader = gl.createShader(gl.VERTEX_SHADER);
        const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);

        // 创建着色器源码
        const vertexSource = document.getElementById('vertex-shader').innerText;
        const fragmentSource = document.getElementById('fragment-shader').innerText;

        //绑定着色器源码
        gl.shaderSource(vertexShader, vertexSource);
        gl.shaderSource(fragmentShader, fragmentSource);

        // 编译着色器源码
        gl.compileShader(vertexShader);
        gl.compileShader(fragmentShader);

        // 创建程序对象
        const program = gl.createProgram();
        gl.attachShader(program, vertexShader);
        gl.attachShader(program, fragmentShader);

        // 链接
        gl.linkProgram(program);
        gl.useProgram(program);

        const vertices = new Float32Array([
            0.5,
            0.5,  // 第一个点的 x & y 坐标
            0.5,
            -0.5,  // 第二个点的 x & y 坐标
        ]);
        const colors = new Float32Array([
            0.0, 0.0, 0.0, 1.0,
            1.0, 0.0, 0.0, 0.0,
        ]);
        const buffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

        const posLocation = gl.getAttribLocation(program, 'aPosition');

        // 告诉解析器,怎么解析我们缓冲区里面的数据
        gl.vertexAttribPointer(posLocation, 2, gl.FLOAT, false, 2 * Float32Array.BYTES_PER_ELEMENT, 0);

        gl.enableVertexAttribArray(posLocation);  // 启用顶点属性数组

        const colorLocation = gl.getAttribLocation(program, 'aColor');
        const colorBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);
        gl.vertexAttribPointer(colorLocation, 4, gl.FLOAT, false, 4 * Float32Array.BYTES_PER_ELEMENT, 0);
        gl.enableVertexAttribArray(colorLocation);

        // 绘制
        gl.drawArrays(gl.LINES, 0, 2);
    </script>

第五节 单个 buffer 渲染颜色

这一节的显示效果,和前面一样,只不过我们是把写法优化一下。把顶点的坐标和颜色写到一个文件里,并告诉解析器怎么解析这个复杂的顶点数据。也就是怎么用好 vertexAttribPointer() 这个函数。

好,现在我们把 顶点坐标 和 顶点颜色 写到一个数组里,注释之前那个颜色数组:

const vertices = new Float32Array([
    0.5, 0.5,  // 第一个点的 x & y 坐标
    0.0, 0.0, 0.0, 1.0,  // 第一个点的颜色
    0.5, -0.5,  // 第二个点的 x & y 坐标
    1.0, 0.0, 0.0, 0.0,  // 第二个点的颜色
]);
// const colors = new Float32Array([
//     0.0, 0.0, 0.0, 1.0,
//     1.0, 0.0, 0.0, 0.0,
// ]);

之后,就是修改坐标颜色的 vertexAttribPointer() 了,注意将颜色缓冲区的 colors 改成 vertices

// 坐标的缓冲区
gl.vertexAttribPointer(posLocation, 2, gl.FLOAT, false, 6 * Float32Array.BYTES_PER_ELEMENT, 0);  // 将跨度由 2 个单位,改成 6 个单位

// 颜色的缓冲区
const colorLocation = gl.getAttribLocation(program, 'aColor');
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);   // 将 `colors` 改成 `vertices`
gl.vertexAttribPointer(colorLocation, 4, gl.FLOAT, false, 4 * Float32Array.BYTES_PER_ELEMENT, 2 * Float32Array.BYTES_PER_ELEMENT);
gl.enableVertexAttribArray(colorLocation);

我们来对比一下颜色缓冲区的修改前后两者的 vertexAttribPointer()

gl.vertexAttribPointer(colorLocation, 4, gl.FLOAT, false, 4 * Float32Array.BYTES_PER_ELEMENT, 0);
// 修改后:
gl.vertexAttribPointer(colorLocation, 4, gl.FLOAT, false, 4 * Float32Array.BYTES_PER_ELEMENT, 2 * Float32Array.BYTES_PER_ELEMENT);

我们修改的是最后那个参数,那个参数的意思是“从哪里开始”。那么这段代码的含义就是,隔 2 个单位开始解析一组。

于是,我们就可以在一个数组里,传入两种信息了!

完整代码

本节的完整代码为:

    <canvas class="canvas" width="500" height="500"></canvas>

    <!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec2 aPosition;
        attribute vec4 aColor;
        varying vec4 vColor;
        void main(){
            gl_Position = vec4(aPosition, 0.0, 1.0);
            vColor = aColor;
        }
    </script>

    <!-- 片元着色器--><script id="fragment-shader" type="x-shader/x-fragment">
        precision highp float;  // 声明精度
        varying vec4 vColor;
        void main(){
            gl_FragColor = vColor;  // 红色
        }
    </script>
    <script>
        const canvas = document.querySelector('.canvas');
        const gl = canvas.getContext('webgl');

        // 创建顶点着色器和片元着色器
        const vertexShader = gl.createShader(gl.VERTEX_SHADER);
        const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);

        // 创建着色器源码
        const vertexSource = document.getElementById('vertex-shader').innerText;
        const fragmentSource = document.getElementById('fragment-shader').innerText;

        //绑定着色器源码
        gl.shaderSource(vertexShader, vertexSource);
        gl.shaderSource(fragmentShader, fragmentSource);

        // 编译着色器源码
        gl.compileShader(vertexShader);
        gl.compileShader(fragmentShader);

        // 创建程序对象
        const program = gl.createProgram();
        gl.attachShader(program, vertexShader);
        gl.attachShader(program, fragmentShader);

        // 链接
        gl.linkProgram(program);
        gl.useProgram(program);

        const vertices = new Float32Array([
            0.5, 0.5,  // 第一个点的 x & y 坐标
            0.0, 0.0, 0.0, 1.0,  // 第一个点的颜色
            0.5, -0.5,  // 第二个点的 x & y 坐标
            1.0, 0.0, 0.0, 0.0,  // 第二个点的颜色
        ]);
        // const colors = new Float32Array([
        //     0.0, 0.0, 0.0, 1.0,
        //     1.0, 0.0, 0.0, 0.0,
        // ]);

        const buffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

        const posLocation = gl.getAttribLocation(program, 'aPosition');

        // 告诉解析器,怎么解析我们缓冲区里面的数据
        gl.vertexAttribPointer(posLocation, 2, gl.FLOAT, false, 6 * Float32Array.BYTES_PER_ELEMENT, 0);

        gl.enableVertexAttribArray(posLocation);  // 启用顶点属性数组

        const colorLocation = gl.getAttribLocation(program, 'aColor');
        const colorBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
        gl.vertexAttribPointer(colorLocation, 4, gl.FLOAT, false, 4 * Float32Array.BYTES_PER_ELEMENT, 2 * Float32Array.BYTES_PER_ELEMENT);
        gl.enableVertexAttribArray(colorLocation);

        // 绘制
        gl.drawArrays(gl.LINES, 0, 2);
    </script>

第六节 抽离代码 & 画彩色三角形

现在,我们来到 WebGL 的经典的画三角形了!

首先,我们前面有很多代码很赘余,属于固定搭配,不需要每次编码都从头写,所以,我们就以模块式的方式将其抽离出去(当然,这个不是 WebGL 的必备知识,可以忽略这个步骤):

我们在本目录建立一个文件夹 utiles,在里面添加一个 js 文件 initShader.js,也就是 './utiles/initShader.js',然后我们写入以下内容:

export default function(gl){
    // 创建顶点着色器和片元着色器
    const vertexShader = gl.createShader(gl.VERTEX_SHADER);
    const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);

    // 创建着色器源码
    const vertexSource = document.getElementById('vertex-shader').innerText;
    const fragmentSource = document.getElementById('fragment-shader').innerText;

    //绑定着色器源码
    gl.shaderSource(vertexShader, vertexSource);
    gl.shaderSource(fragmentShader, fragmentSource);

    // 编译着色器源码
    gl.compileShader(vertexShader);
    gl.compileShader(fragmentShader);

    // 创建程序对象
    const program = gl.createProgram();
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);

    // 链接
    gl.linkProgram(program);
    gl.useProgram(program);

    return program;
}

这样,我们的主文件,就可以这样轻松写了,不用搞那么一大堆了:

<script type="module">  // 注意,这里一定要写 'module' !!!
    import initShader from './utiles/initShader.js';  // 引入我们的初始化着色器模块
    const canvas = document.querySelector('.canvas');
    const gl = canvas.getContext('webgl');
    const program = initShader(gl);  // 程序对象直接就生成了

然后,就是画三角形了。

三角形的点很少,就三个,这是我们的点数据:

const vertices = new Float32Array([
    -0.5, -0.5, 1.0, 0.0, 0.0, 0.0,  // 第一个点的坐标和颜色
    0.5, -0.5, 0.0, 1.0, 0.0, 0.0,  // 第二个
    -0.0, 0.5, 0.0, 0.0, 1.0, 0.0,  // 第三个
]);

然后,绘制方式,就不是【线条】(LINES)了,而是【三角形】(TRIANGLES)了。

gl.drawArrays(gl.TRIANGLES, 0, 3);

最终呈现的效果如图所示:

img

完整代码

本节的完整代码为:

    <canvas class="canvas" width="500" height="500"></canvas>

    <!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec2 aPosition;
        attribute vec4 aColor;
        varying vec4 vColor;
        void main(){
            gl_Position = vec4(aPosition, 0.0, 1.0);
            vColor = aColor;
        }
    </script>

    <!-- 片元着色器--><script id="fragment-shader" type="x-shader/x-fragment">
        precision highp float;  // 声明精度
        varying vec4 vColor;
        void main(){
            gl_FragColor = vColor;  // 红色
        }
    </script>
    <script type="module">
        import initShader from './utiles/initShader.js';
        const canvas = document.querySelector('.canvas');
        const gl = canvas.getContext('webgl');
        const program = initShader(gl);

        const vertices = new Float32Array([
            -0.5, -0.5, 1.0, 0.0, 0.0, 0.0,
            0.5, -0.5, 0.0, 1.0, 0.0, 0.0,
            -0.0, 0.5, 0.0, 0.0, 1.0, 0.0,
        ]);

        const buffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

        const posLocation = gl.getAttribLocation(program, 'aPosition');
        const colorLocation = gl.getAttribLocation(program, 'aColor');

        // 告诉解析器,怎么解析我们缓冲区里面的数据
        gl.vertexAttribPointer(posLocation, 2, gl.FLOAT, false, 6 * Float32Array.BYTES_PER_ELEMENT, 0);

        gl.enableVertexAttribArray(posLocation);  // 启用顶点属性数组

        const colorBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
        gl.vertexAttribPointer(colorLocation, 4, gl.FLOAT, false, 4 * Float32Array.BYTES_PER_ELEMENT, 2 * Float32Array.BYTES_PER_ELEMENT);
        gl.enableVertexAttribArray(colorLocation);

        // 绘制
        gl.drawArrays(gl.TRIANGLES, 0, 3);
    </script>

第七节 图元的七种绘制方式

上面有举例,webGL 里的几种绘制方式。

现在我们来简单逐个试一下:

我们先创建 6 个点:

const vertices = new Float32Array([
    -1.0, 1.0, 1.0, 0.0, 0.0, 0.0,
    -0.5, -1.0, 0.0, 1.0, 0.0, 0.0,
    -0.5, 1.0, 0.0, 0.0, 1.0, 0.0,
    0.0, -1.0, 0.0, 1.0, 0.0, 0.0,
    0.0, 1.0, 0.0, 1.0, 0.0, 0.0,
    0.5, -1.0, 0.0, 0.0, 1.0, 0.0,
]);

然后,把这 6 个点都显示出来。

gl.drawArrays(gl.POINTS, 0, 6); 这是最基本的点(注意,有两个点,是白色的,无法被看到):

img

保留这 6 个点,我们向下追加代码,使用其他方式绘制。

gl.drawArrays(gl.LINES, 0, 6); 每两个顶点点,画一次线,顶点不重复使用:

img

gl.drawArrays(gl.LINE_STRIP, 0, 6);,顶点可以重复使用,但不闭合:

img

gl.drawArrays(gl.LINE_LOOP, 0, 6);,顶点可以重复使用的绘制线条,且闭合:

img

gl.drawArrays(gl.TRIANGLES, 0, 6); ,顶点不重复使用,绘制三角形:

img

gl.drawArrays(gl.TRIANGLE_FAN, 0, 6);,顶点可重复使用,绘制三角形(是的,绘制多边形,其实就是绘制多个三角形):

img

gl.drawArrays(gl.TRIANGLE_STRIP, 0, 6);,绘制一个三角形条带(换言之,都基于第一个点来画三角形):

img

完整代码

以下是完整代码:

    <canvas class="canvas" width="500" height="500"></canvas>

    <!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec2 aPosition;
        attribute vec4 aColor;
        varying vec4 vColor;
        void main(){
            gl_Position = vec4(aPosition, 0.0, 1.0);
            gl_PointSize = 10.0;
            vColor = aColor;
        }
    </script>

    <!-- 片元着色器--><script id="fragment-shader" type="x-shader/x-fragment">
        precision highp float;  // 声明精度
        varying vec4 vColor;
        void main(){
            gl_FragColor = vColor;  // 红色
        }
    </script>
    <script type="module">
        import initShader from './utiles/initShader.js';
        const canvas = document.querySelector('.canvas');
        const gl = canvas.getContext('webgl');
        const program = initShader(gl);

        const vertices = new Float32Array([
            -1.0, 1.0, 1.0, 0.0, 0.0, 0.0,
            -0.5, -1.0, 0.0, 1.0, 0.0, 0.0,
            -0.5, 1.0, 0.0, 0.0, 1.0, 0.0,
            0.0, -1.0, 0.0, 1.0, 0.0, 0.0,
            0.0, 1.0, 0.0, 1.0, 0.0, 0.0,
            0.5, -1.0, 0.0, 0.0, 1.0, 0.0,
        ]);

        const buffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

        const posLocation = gl.getAttribLocation(program, 'aPosition');
        const colorLocation = gl.getAttribLocation(program, 'aColor');

        // 告诉解析器,怎么解析我们缓冲区里面的数据
        gl.vertexAttribPointer(posLocation, 2, gl.FLOAT, false, 6 * Float32Array.BYTES_PER_ELEMENT, 0);

        gl.enableVertexAttribArray(posLocation);  // 启用顶点属性数组

        const colorBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
        gl.vertexAttribPointer(colorLocation, 4, gl.FLOAT, false, 4 * Float32Array.BYTES_PER_ELEMENT, 2 * Float32Array.BYTES_PER_ELEMENT);
        gl.enableVertexAttribArray(colorLocation);

        // 绘制
        // gl.LINE_STRIP 连续的线、gl.LINE_LOOP 闭合的线
        // TRIANGLE_STRIP 三角形条带、gl.TRIANGLE_FAN 三角形扇形(都以第一个点来画三角形)
        // gl.drawArrays(gl.POINTS, 0, 6);
        // gl.drawArrays(gl.LINES, 0, 6);
        // gl.drawArrays(gl.LINE_STRIP, 0, 6);
        // gl.drawArrays(gl.LINE_LOOP, 0, 6);
        // gl.drawArrays(gl.TRIANGLES, 0, 6);
        // gl.drawArrays(gl.TRIANGLE_STRIP, 0, 6);
        // gl.drawArrays(gl.TRIANGLE_FAN, 0, 6);
    </script>

第八节 uniform 传值变换数据

绘制相关的基本内容,已经讲完,现在就是变换了。

在上面,我们用过了 attributevaryingprecision 等变量修饰符,现在我们来使用上面提到过的 uniform 修饰符。

上面说,uniform 定义在整个渲染过程中保持不变的变量,常用于传递变换矩阵、光照参数等,所以它就像常量一样。

我们这一节,就来学习形状的偏移(比如 正方形 的移动)!所谓偏移,其实就是两个向量相加!

我们先画一个正方形,关键代码如下:

const vertices = new Float32Array([  // 正方形的四个顶点
    -0.5, 0.5,
    -0.5, -0.5,
    0.5, -0.5,
    0.5, 0.5,
]);

// ***

// 绘制
gl.drawArrays(gl.POINTS, 0, 4);
gl.drawArrays(gl.LINE_LOOP, 0, 4);

这样,一个正方形就出现了。接着我们在缓冲区向着色器传入一个偏移值,和前面用过的 attribute 传值大法差不多。只不过,配套的两个函数变成了 uniform 家的那种。

const translationLocation = gl.getUniformLocation(program, 'uTranslate');  // 获取属性
const translation = [-0.2, 0.2];  // 偏移内容
gl.uniform2fv(translationLocation, translation);  // 传入着色器

那么我们在着色器,怎么接收呢?如下代码:

attribute vec2 aPosition;
uniform vec2 uTranslate;  // 我们要接收的偏移值
void main(){
    vec2 newPos = uTranslate + aPosition;  // 两个 向量 相加,就是所谓 偏移
    gl_Position = vec4(newPos, 0.0, 1.0);
    gl_PointSize = 10.0;
}

这是最终结果:

img

正方形,向左上偏移了一点。

完整代码

下面是本节的完整代码:

    <canvas class="canvas" width="500" height="500"></canvas>

    <!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec2 aPosition;
        uniform vec2 uTranslate;
        void main(){
            vec2 newPos = uTranslate + aPosition;
            gl_Position = vec4(newPos, 0.0, 1.0);
            gl_PointSize = 10.0;
        }
    </script>

    <!-- 片元着色器--><script id="fragment-shader" type="x-shader/x-fragment">
        precision highp float;  // 声明精度
        varying vec4 vColor;
        void main(){
            gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
        }
    </script>
    <script type="module">
        import initShader from './utiles/initShader.js';
        const canvas = document.querySelector('.canvas');
        const gl = canvas.getContext('webgl');
        const program = initShader(gl);

        const vertices = new Float32Array([
            -0.5, 0.5,
            -0.5, -0.5,
            0.5, -0.5,
            0.5, 0.5,
        ]);

        const buffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

        const posLocation = gl.getAttribLocation(program, 'aPosition');
        const translationLocation = gl.getUniformLocation(program, 'uTranslate');
        const translation = [-0.2, 0.2];
        gl.uniform2fv(translationLocation, translation);

        gl.vertexAttribPointer(posLocation, 2, gl.FLOAT, false, 2 * Float32Array.BYTES_PER_ELEMENT, 0);
        gl.enableVertexAttribArray(posLocation);

        // 绘制
        gl.drawArrays(gl.POINTS, 0, 4);
        gl.drawArrays(gl.LINE_LOOP, 0, 4);
    </script>

第九节 旋转矩阵

既然能上下左右偏移,那就可以旋转。但旋转可没那么轻松。

使用三角函数来推导公式

如果有一个点 A(x1, y1)。旋转 n 度后,形成 A' (x2, y2)。我们知道 x1、y1、n,怎么求 x2,y2 ?

这是一道中学的数学题目,我们需要数学里的三角函数。

我们把图画出来,发现里面有两个三角形:

img

我们先看躺着的三角形,设三角形最长边为 r,根据基本的三角函数,我们可以使用它们推断出 x1y1 的值:

x1 = r * cosa
y1 = r * sina

我们现在的目的是,求立着的三角形的两个坐标 x2y2

根据三角函数里的倍角公式,我们可以这样推导:

x2 = r * cos(a + b)
   = r * (cosa * cosb - sina * sinb)
   = r * cosa * cosb - r * sina * sinb
   = x1 * cosb - y1 * sinb
y2 = r * sin(a + b)
   = r * (sina * cosb + sinb * cosa)
   = r * sina * cosb + r * sinb * cosa
   = y1 * cosb + x1 * sinb

x2 = x1 * cosb - y1 * sinb
y2 = y1 * cosb + x1 * sinb

好,现在我们证明出来了。如果我们需要将其完美的融入到程序里去,我们需要用到矩阵。

矩阵

我们来简单讲一下矩阵的基础知识,下面是一个矩阵的相加 :

$\begin{bmatrix} 1 & 1 & 1 \\ 2 & 2 & 2 \\ 3 & 3 & 3 \end{bmatrix}$ + $\begin{bmatrix} 1 & 1 & 1 \\ 1 & 1 & 1 \\ 1 & 1 & 1 \end{bmatrix}$ = $\begin{bmatrix} 2 & 2 & 2 \\ 3 & 3 & 3 \\ 4 & 4 & 4 \end{bmatrix}$

两个矩阵如果要相加,则必须要有相同的维度。然后每个相同位置的元素相加即可。

两个矩阵如果要相乘,则不需要相同的元素。但第一个元素的每行的列数,必须等于第二个元素的行数:

$\begin{bmatrix} 1 & 1 & 1 \\ 2 & 2 & 2 \\ 3 & 3 & 3 \end{bmatrix}$ * $\begin{bmatrix} 1 \\ 2 \\ 3 \end{bmatrix}$ = $\begin{bmatrix} 6 \\ 12 \\ 18 \end{bmatrix}$

其计算原理过程如下所示:

第一行:1 1 + 1 2 + 1 * 3 = 6

第二行:2 1 + 2 2 + 2 * 3 = 12

第三行:3 1 + 3 2 + 3 * 3 = 18

我们可以根据这个原理,简单的推导出一个适用于旋转的矩阵:

$\begin{bmatrix} cosb & -sinb \\ sinb & cosb \end{bmatrix}$ * $\begin{bmatrix} x \\ y \end{bmatrix}$ = $\begin{bmatrix} cosb * x - sinb * y \\ sinb * x + cosb * y \end{bmatrix}$

代码来实现

我们现在可以写代码了,还是上一节的四边形,现在我们只需将【顶点着色器】修改:

    <!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec2 aPosition;
        void main(){
            float radian = radians(45.0);
            float sin = sin(radian);
            float cos = cos(radian);
            mat4 roatate = 
            - mat4(  // 旋转矩阵
                cos, -sin, 0, 0,
                sin, cos, 0, 0,
                0, 0, 1, 0,
                0, 0, 0, 1
            );

            gl_Position = roatate * vec4(aPosition, 0.0, 1.0);
            gl_PointSize = 10.0;
        }
    </script>

这里又多了很多陌生的函数和修饰符。

gl_Position 中,直接将矩阵和变量相乘即可。这样,我们就可以得到一个旋转 45 度的四边形了!

结果如下所示:

img

这便是 webGL 的变换基础,我们可以在 2D 上旋转,那就可以在 3D 上旋转。下面我们就要进入三维世界了!

三维旋转的三角函数矩阵

下面是 X Y Z 三个轴上的旋转矩阵,我们在后面的小节可以用到:

$\begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & cos & -sin & 0 \\ 0 & sin & cos & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}$
$\begin{bmatrix} cos & 0 & -sin & 0 \\ 0 & 1 & 0 & 0 \\ sin & 0 & cos & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}$
$\begin{bmatrix} cos & -sin & 0 & 0 \\ sin & cos & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}$

完整代码

下面是本节的完整代码:

    <canvas class="canvas" width="500" height="500"></canvas>

    <!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec2 aPosition;
        void main(){
            float radian = radians(45.0);
            float sin = sin(radian);
            float cos = cos(radian);
            mat4 roatate = mat4(  // 旋转矩阵
                cos, -sin, 0, 0,
                sin, cos, 0, 0,
                0, 0, 1, 0,
                0, 0, 0, 1
            );

            gl_Position = roatate * vec4(aPosition, 0.0, 1.0);
            gl_PointSize = 10.0;
        }
    </script>

    <!-- 片元着色器--><script id="fragment-shader" type="x-shader/x-fragment">
        precision highp float;  // 声明精度
        varying vec4 vColor;
        void main(){
            gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
        }
    </script>
    <script type="module">
        import initShader from './utiles/initShader.js';
        const canvas = document.querySelector('.canvas');
        const gl = canvas.getContext('webgl');
        const program = initShader(gl);

        const vertices = new Float32Array([
            -0.5, 0.5,
            -0.5, -0.5,
            0.5, -0.5,
            0.5, 0.5,
        ]);

        const buffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

        const posLocation = gl.getAttribLocation(program, 'aPosition');

        // 告诉解析器,怎么解析我们缓冲区里面的数据
        gl.vertexAttribPointer(posLocation, 2, gl.FLOAT, false, 2 * Float32Array.BYTES_PER_ELEMENT, 0);

        gl.enableVertexAttribArray(posLocation);  // 启用顶点属性数组

        // 绘制
        gl.drawArrays(gl.POINTS, 0, 4);
        gl.drawArrays(gl.LINE_LOOP, 0, 4);
    </script>

第十节 线框立方体

立方体有 8 个点,我们来把坐标写出来,然后绘制出来,关键的 js 代码为:

const vertices = new Float32Array([
            // 第一个四边形
            0.5, 0.5, 0.5,
            -0.5, 0.5, 0.5,
            -0.5, -0.5, 0.5,
            0.5, -0.5, 0.5,

            // 第二个四边形
            0.5, 0.5, -0.5,
            -0.5, 0.5, -0.5,
            -0.5, -0.5, -0.5,
            0.5, -0.5, -0.5,
]);

// ***

// 注意,现在是 3 维,每个元素使用三个值,且跨度为 3
gl.vertexAttribPointer(posLocation, 3, gl.FLOAT, false, 3 * Float32Array.BYTES_PER_ELEMENT, 0);

// ***

// 绘制第一个正方形
gl.drawArrays(gl.POINTS, 0, 4);
gl.drawArrays(gl.LINE_LOOP, 0, 4);

// 绘制第二个正方形
gl.drawArrays(gl.POINTS, 4, 4);
gl.drawArrays(gl.LINE_LOOP, 4, 4);

当然,我们也要把我们的三维矩阵放到着色器里:

attribute vec4 aPosition;
void main(){
float radian = radians(10.0);
float sin = sin(radian);
float cos = cos(radian);
mat4 mx = mat4(  // 旋转矩阵
    1, 0, 0, 0,
    0, cos, -sin, 0,
    0, sin, cos, 0,
    0, 0, 0, 1
);
mat4 my = mat4(  // 旋转矩阵
    cos, 0, -sin, 0,
    0, 1, 0, 0,
    sin, 0, cos, 0,
    0, 0, 0, 1
);
mat4 mz = mat4(  // 旋转矩阵
    cos, -sin, 0, 0,
    sin, cos, 0, 0,
    0, 0, 1, 0,
    0, 0, 0, 1
);

gl_Position = mx * my * aPosition;
gl_PointSize = 10.0;
}

画出来的图是这样的:

img

现在,还缺 4 根线。我们可以再添加四根线,现在的 vertices 是这个样子:

const vertices = new Float32Array([
            // 第一个四边形
            0.5, 0.5, 0.5,
            -0.5, 0.5, 0.5,
            -0.5, -0.5, 0.5,
            0.5, -0.5, 0.5,

            // 第二个四边形
            0.5, 0.5, -0.5,
            -0.5, 0.5, -0.5,
            -0.5, -0.5, -0.5,
            0.5, -0.5, -0.5,

            0.5, 0.5, 0.5,
            0.5, 0.5, -0.5,

            -0.5, 0.5, 0.5,
            -0.5, 0.5, -0.5,

            -0.5, -0.5, 0.5,
            -0.5, -0.5, -0.5,

            0.5, -0.5, 0.5,
            0.5, -0.5, -0.5,
]);

// **

// 下面再添加绘制动作

gl.drawArrays(gl.LINES, 8, 2 * 4); // 从第 8 组开始画线,一共画 8 个顶点

效果如下:

img

我们的立方体,就这样被轻松的画了出来。

完整代码

下面是本节的完整代码。

    <canvas class="canvas" width="500" height="500"></canvas>

    <!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec4 aPosition;
        void main(){
            float radian = radians(10.0);
            float sin = sin(radian);
            float cos = cos(radian);
            mat4 mx = mat4(  // X 轴 旋转矩阵
                1, 0, 0, 0,
                0, cos, -sin, 0,
                0, sin, cos, 0,
                0, 0, 0, 1
            );
            mat4 my = mat4(  // Y 轴 旋转矩阵
                cos, 0, -sin, 0,
                0, 1, 0, 0,
                sin, 0, cos, 0,
                0, 0, 0, 1
            );
            mat4 mz = mat4(  // Z 轴 旋转矩阵
                cos, -sin, 0, 0,
                sin, cos, 0, 0,
                0, 0, 1, 0,
                0, 0, 0, 1
            );

            gl_Position = mx * my * aPosition;
            gl_PointSize = 10.0;
        }
    </script>

    <!-- 片元着色器--><script id="fragment-shader" type="x-shader/x-fragment">
        precision highp float;  // 声明精度
        varying vec4 vColor;
        void main(){
            gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
        }
    </script>
    <script type="module">
        import initShader from './utiles/initShader.js';
        const canvas = document.querySelector('.canvas');
        const gl = canvas.getContext('webgl');
        const program = initShader(gl);

        const vertices = new Float32Array([
            0.5, 0.5, 0.5,
            -0.5, 0.5, 0.5,
            -0.5, -0.5, 0.5,
            0.5, -0.5, 0.5,

            0.5, 0.5, -0.5,
            -0.5, 0.5, -0.5,
            -0.5, -0.5, -0.5,
            0.5, -0.5, -0.5,

            0.5, 0.5, 0.5,
            0.5, 0.5, -0.5,

            -0.5, 0.5, 0.5,
            -0.5, 0.5, -0.5,

            -0.5, -0.5, 0.5,
            -0.5, -0.5, -0.5,

            0.5, -0.5, 0.5,
            0.5, -0.5, -0.5,
        ]);

        const buffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

        const posLocation = gl.getAttribLocation(program, 'aPosition');

        // 告诉解析器,怎么解析我们缓冲区里面的数据
        gl.vertexAttribPointer(posLocation, 3, gl.FLOAT, false, 3 * Float32Array.BYTES_PER_ELEMENT, 0);

        gl.enableVertexAttribArray(posLocation);  // 启用顶点属性数组

        // 绘制
        gl.drawArrays(gl.POINTS, 0, 4);
        gl.drawArrays(gl.LINE_LOOP, 0, 4);
        gl.drawArrays(gl.POINTS, 4, 4);
        gl.drawArrays(gl.LINE_LOOP, 4, 4);

        gl.drawArrays(gl.LINES, 8, 2 * 4); // 从第 8 组开始画线,一共画 8 个顶点
    </script>

第十一节 使用索引绘制立方体

在上一节中,我们绘制了一个立方体,但那种办法并不好。在复杂的环境和高频渲染中会达到性能瓶颈。而且,三角形是属于面的,上节中画的线框,它不具备面的特性,无法添加纹理颜色等。

所以我们主流的绘制立方体的办法,还是使用三角形,在本节,我们使用绘制三角形来绘制一个立方体。相较上节内容,这节会复杂一点。我们会用到索引。

这是一个立方体:

img

每个面都是两个三角形,每个三角形是 3 个点,一共 12 个三角形,一共是 2 3 6 = 36 个点(包含共用的点),我们一会儿的索引,会每三个点来定义一个三角形,将所有三角形的点整理出来,然后通过循环语句,将面一个个绘制出来。然后我们还会将每个面添加颜色。

首先是每个面的点的坐标。当然,里面所有的点都会被重复使用:

const vertices = new Float32Array([
            // 前面
            -0.5, -0.5, 0.5,
            0.5, -0.5, 0.5,
            0.5, 0.5, 0.5,
            -0.5, 0.5, 0.5,

            // 后面
            -0.5, -0.5, -0.5,
            -0.5, 0.5, -0.5,
            0.5, 0.5, -0.5,
            0.5, -0.5, -0.5,

            // 上面
            -0.5, 0.5, -0.5,
            -0.5, 0.5, 0.5,
            0.5, 0.5, 0.5,
            0.5, 0.5, -0.5,

            // 下面
            -0.5, -0.5, -0.5,
            0.5, -0.5, -0.5,
            0.5, -0.5, 0.5,
            -0.5, -0.5, 0.5,

            // 右面
            0.5, -0.5, -0.5,
            0.5, 0.5, -0.5,
            0.5, 0.5, 0.5,
            0.5, -0.5, 0.5,

            // 左面
            -0.5, -0.5, -0.5,
            -0.5, -0.5, 0.5,
            -0.5, 0.5, 0.5,
            -0.5, 0.5, -0.5,
]);

然后是索引:

// 索引
const indices = new Uint16Array([
    0,1,2, 0,2,3,  // 前面的两个三角形
    4,5,6, 4,6,7,  // 后面
    8,9,10, 8,10,11,  // 上面
    12,13,14, 12,14,15,  // 下面
    16,17,18, 16,18,19,  // 右边
    20,21,22, 20,22,23,  // 左边
]);

这个索引是什么意思呢?“前面”的“0,1,2,0,2,3” 就是上面图中的 1 2 3 4 四个顶点。0,1,2 是一个三角形,0,2,3 是一个三角形。一共 12 个三角形。

之后就是绑定了,和 坐标 存储入缓冲区一样,但是这里不是gl.ARRAY_BUFFER,而是 gl.ELEMENT_ARRAY_BUFFER

// 坐标和索引进行绑定
const indexBuffer = gl.createBuffer();  // 创建索引缓冲区
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

为了更美观的显示效果,我们再给每个面都涂上颜色。

const faceColors = [
    [1.0, 0.0, 0.0, 1.0],  // 前
    [0.0, 1.0, 0.0, 1.0],  // 后
    [0.0, 0.0, 1.0, 1.0],  // 上
    [1.0, 1.0, 0.0, 1.0],  // 下
    [1.0, 0.0, 1.0, 1.0],  // 右
    [0.0, 1.0, 1.0, 1.0],  // 左
];
const colorLocation = gl.getUniformLocation(program, 'uColor');  // 别忘了获取颜色属性

接下来就是核心了!

for (let index = 0; index < 6; index++) {
    gl.uniform4fv(colorLocation, faceColors[index]);
    gl.drawElements(
        gl.TRIANGLES,  // 三角形模式
        6, // 6 个顶点(两个三角形)
        gl.UNSIGNED_SHORT,  // 索引 的数据格式
        index * 6 * Uint16Array.BYTES_PER_ELEMENT  // 开始的位置
    );  // 绘制
}

我们这次是使用一个循环语句来绘制我们的图形。

首先,我们第一个应该去关注的,应该是 drawElements() 这个绘制方法。在之前,我们一直使用的 drawArrays()。这两种方法的主要区别是:drawElements() 管理着具有索引结构的三维模型数据。

drawElements(mode, count, type, offset);

这个循环,每次绘制两个三角形,也就是一个面,6 个顶点。

最终的结果是这样:

img

一个很奇怪的图像..... 这是因为我们没有开启非常非常非常重要的一个声明!深度测试 DEPTH_TEST(也加隐藏面消除)。在缓冲区里面先后顺序的原因,它们叠加了。我们需要让程序重新计算,以防止远处的物体遮住近处的物体。

在我们程序的上面,写上 gl.enable(gl.DEPTH_TEST); ,最终效果如图所示:

img

完整代码

本节的完整代码为:

<canvas class="canvas" width="500" height="500"></canvas>

    <!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec4 aPosition;
        void main(){
            float radian = radians(30.0);
            float sin = sin(radian);
            float cos = cos(radian);
            mat4 mx = mat4(  // 旋转矩阵
                1, 0, 0, 0,
                0, cos, -sin, 0,
                0, sin, cos, 0,
                0, 0, 0, 1
            );
            mat4 my = mat4(  // 旋转矩阵
                cos, 0, -sin, 0,
                0, 1, 0, 0,
                sin, 0, cos, 0,
                0, 0, 0, 1
            );
            mat4 mz = mat4(  // 旋转矩阵
                cos, -sin, 0, 0,
                sin, cos, 0, 0,
                0, 0, 1, 0,
                0, 0, 0, 1
            );

            gl_Position = mx * my * aPosition;
            gl_PointSize = 10.0;
        }
    </script>

    <!-- 片元着色器--><script id="fragment-shader" type="x-shader/x-fragment">
        precision mediump float;  // 声明精度
        uniform vec4 uColor;
        void main(){
            gl_FragColor = uColor;
        }
    </script>
    <script type="module">
        import initShader from './utiles/initShader.js';
        const canvas = document.querySelector('.canvas');
        const gl = canvas.getContext('webgl');
        const program = initShader(gl);

        // 启用深度测试
        gl.enable(gl.DEPTH_TEST);

        const vertices = new Float32Array([
            // 前面
            -0.5, -0.5, 0.5,
            0.5, -0.5, 0.5,
            0.5, 0.5, 0.5,
            -0.5, 0.5, 0.5,

            // 后面
            -0.5, -0.5, -0.5,
            -0.5, 0.5, -0.5,
            0.5, 0.5, -0.5,
            0.5, -0.5, -0.5,

            // 上面
            -0.5, 0.5, -0.5,
            -0.5, 0.5, 0.5,
            0.5, 0.5, 0.5,
            0.5, 0.5, -0.5,

            // 下面
            -0.5, -0.5, -0.5,
            0.5, -0.5, -0.5,
            0.5, -0.5, 0.5,
            -0.5, -0.5, 0.5,

            // 右面
            0.5, -0.5, -0.5,
            0.5, 0.5, -0.5,
            0.5, 0.5, 0.5,
            0.5, -0.5, 0.5,

            // 左面
            -0.5, -0.5, -0.5,
            -0.5, -0.5, 0.5,
            -0.5, 0.5, 0.5,
            -0.5, 0.5, -0.5,

        ]);

        const buffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

        // 索引
        const indices = new Uint16Array([
            0,1,2, 0,2,3,
            4,5,6, 4,6,7,
            8,9,10, 8,10,11,
            12,13,14, 12,14,15,
            16,17,18, 16,18,19,
            20,21,22, 20,22,23,
        ]);

        // 坐标和索引进行绑定
        const indexBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
        gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

        const posLocation = gl.getAttribLocation(program, 'aPosition');

        // 告诉解析器,怎么解析我们缓冲区里面的数据
        gl.vertexAttribPointer(posLocation, 3, gl.FLOAT, false, 3 * Float32Array.BYTES_PER_ELEMENT, 0);

        gl.enableVertexAttribArray(posLocation);  // 启用顶点属性数组

        const faceColors = [
            [1.0, 0.0, 0.0, 1.0],
            [0.0, 1.0, 0.0, 1.0],
            [0.0, 0.0, 1.0, 1.0],
            [1.0, 1.0, 0.0, 1.0],
            [1.0, 0.0, 1.0, 1.0],
            [0.0, 1.0, 1.0, 1.0],
        ];

        const colorLocation = gl.getUniformLocation(program, 'uColor');
        for (let index = 0; index < 6; index++) {
            gl.uniform4fv(colorLocation, faceColors[index]);
            gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, index * 6 * Uint16Array.BYTES_PER_ELEMENT);  // 绘制
        }
    </script>

第十二节 旋转动画

至此,webgl 的基础入门内容就搞定了。现在我们来添加一个炫酷的动画,让三维的效果更加的突出和直观!

其实,我们着色器没必要写矩阵变换,我们其实可以将矩阵写到到 js 里,这样也可以让程序的可扩展性更强。着色器代码越简单越好。

我们删去着色器里面的矩阵变换代码,让两个矩阵变换的矩阵以 uniform 格式接收缓冲区的数据,就像我们第八节的 uniform 接收我们的平移转换数值一样(这里只写 X 和 Y 轴的旋转矩阵变换就行了,方便演示),只不过我们传递的是矩阵,使用的是另一个方法 uniformMatrix4fv

在 js 里写上 javascript 格式的矩阵,直接传过去:

let deg = 30;  // 旋转度数
const cosB = Math.cos(deg * Math.PI / 180);
const sinB = Math.sin(deg * Math.PI / 180);
gl.uniformMatrix4fv(u_x_matrix, false, new Float32Array([
    1, 0, 0, 0,
    0, cosB, -sinB, 0,
    0, sinB, cosB, 0,
    0, 0, 0, 1
]));
gl.uniformMatrix4fv(u_y_matrix, false, new Float32Array([
    cosB, 0, -sinB, 0,
    0, 1, 0, 0,
    sinB, 0, cosB, 0,
    0, 0, 0, 1
]));

之后,我们使用在 javascript 广泛使用的内置动画函数 requestAnimationFrame,这个函数和 settimeout 不一样,它更智能,无需特意去设置时间间隔等,能根据机器性能等生成一个每秒大约 60 帧的动画。

它的用法大概如此:

function render() {

    // 我们的代码写到此处,便可以每秒约 60 次的频率进行执行
    // ...
    // ...

    window.requestAnimationFrame(render)
}
window.requestAnimationFrame(render) // 大约每秒 60 帧

我们将上面的代码整理起来,放到 render() 函数里,如下所示:

let deg = 30;
function render() {
    deg += 0.5;  // 速度
    const cosB = Math.cos(deg * Math.PI / 180);
    const sinB = Math.sin(deg * Math.PI / 180);
    gl.uniformMatrix4fv(u_x_matrix, false, new Float32Array([
        1, 0, 0, 0,
        0, cosB, -sinB, 0,
        0, sinB, cosB, 0,
        0, 0, 0, 1
    ]));
    gl.uniformMatrix4fv(u_y_matrix, false, new Float32Array([
        cosB, 0, -sinB, 0,
        0, 1, 0, 0,
        sinB, 0, cosB, 0,
        0, 0, 0, 1
    ]));
    for (let index = 0; index < 6; index++) {
        gl.uniform4fv(colorLocation, faceColors[index]);
        gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, index * 6 * Uint16Array.BYTES_PER_ELEMENT);  // 绘制
    }
    window.requestAnimationFrame(render)
}
window.requestAnimationFrame(render) // 大约每秒 60 帧

打开浏览器,效果如下:

img

本节的完整代码如下:

    <canvas class="canvas" width="500" height="500"></canvas>

    <!-- 顶点着色器--><script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec4 aPosition;
        uniform mat4 u_x_matrix;
        uniform mat4 u_y_matrix;
        void main(){
            gl_Position = u_x_matrix * u_y_matrix * aPosition;
            gl_PointSize = 10.0;
        }
    </script>

    <!-- 片元着色器--><script id="fragment-shader" type="x-shader/x-fragment">
        precision mediump float;  // 声明精度
        uniform vec4 uColor;
        void main(){
            gl_FragColor = uColor;
        }
    </script>
    <script type="module">
        import initShader from './utiles/initShader.js';
        const canvas = document.querySelector('.canvas');
        const gl = canvas.getContext('webgl');
        const program = initShader(gl);

        // 启用深度测试(很重要,课程里没有讲到)
        gl.enable(gl.DEPTH_TEST);

        const vertices = new Float32Array([
            // 前面
            -0.5, -0.5, 0.5,
            0.5, -0.5, 0.5,
            0.5, 0.5, 0.5,
            -0.5, 0.5, 0.5,

            // 后面
            -0.5, -0.5, -0.5,
            -0.5, 0.5, -0.5,
            0.5, 0.5, -0.5,
            0.5, -0.5, -0.5,

            // 上面
            -0.5, 0.5, -0.5,
            -0.5, 0.5, 0.5,
            0.5, 0.5, 0.5,
            0.5, 0.5, -0.5,

            // 下面
            -0.5, -0.5, -0.5,
            0.5, -0.5, -0.5,
            0.5, -0.5, 0.5,
            -0.5, -0.5, 0.5,

            // 右面
            0.5, -0.5, -0.5,
            0.5, 0.5, -0.5,
            0.5, 0.5, 0.5,
            0.5, -0.5, 0.5,

            // 左面
            -0.5, -0.5, -0.5,
            -0.5, -0.5, 0.5,
            -0.5, 0.5, 0.5,
            -0.5, 0.5, -0.5,

        ]);

        const buffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

        // 索引
        const indices = new Uint16Array([
            0,1,2, 0,2,3,
            4,5,6, 4,6,7,
            8,9,10, 8,10,11,
            12,13,14, 12,14,15,
            16,17,18, 16,18,19,
            20,21,22, 20,22,23,
        ]);

        // 坐标和索引进行绑定
        const indexBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
        gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

        const posLocation = gl.getAttribLocation(program, 'aPosition');

        // 告诉解析器,怎么解析我们缓冲区里面的数据
        gl.vertexAttribPointer(posLocation, 3, gl.FLOAT, false, 3 * Float32Array.BYTES_PER_ELEMENT, 0);

        gl.enableVertexAttribArray(posLocation);  // 启用顶点属性数组

        const faceColors = [
            [1.0, 0.0, 0.0, 1.0],
            [0.0, 1.0, 0.0, 1.0],
            [0.0, 0.0, 1.0, 1.0],
            [1.0, 1.0, 0.0, 1.0],
            [1.0, 0.0, 1.0, 1.0],
            [0.0, 1.0, 1.0, 1.0],
        ];

        const colorLocation = gl.getUniformLocation(program, 'uColor');
        const u_x_matrix = gl.getUniformLocation(program, 'u_x_matrix');
        const u_y_matrix = gl.getUniformLocation(program, 'u_y_matrix');

        /*
            mat4 mx = mat4(  // 旋转矩阵
                1, 0, 0, 0,
                0, cos, -sin, 0,
                0, sin, cos, 0,
                0, 0, 0, 1
            );
            mat4 my = mat4(  // 旋转矩阵
                cos, 0, -sin, 0,
                0, 1, 0, 0,
                sin, 0, cos, 0,
                0, 0, 0, 1
            );
            mat4 mz = mat4(  // 旋转矩阵
                cos, -sin, 0, 0,
                sin, cos, 0, 0,
                0, 0, 1, 0,
                0, 0, 0, 1
            );
        */

        let deg = 30;
        function render() {
            deg += 0.5;  // 速度
            const cosB = Math.cos(deg * Math.PI / 180);
            const sinB = Math.sin(deg * Math.PI / 180);
            gl.uniformMatrix4fv(u_x_matrix, false, new Float32Array([
                1, 0, 0, 0,
                0, cosB, -sinB, 0,
                0, sinB, cosB, 0,
                0, 0, 0, 1
            ]));
            gl.uniformMatrix4fv(u_y_matrix, false, new Float32Array([
                cosB, 0, -sinB, 0,
                0, 1, 0, 0,
                sinB, 0, cosB, 0,
                0, 0, 0, 1
            ]));
            for (let index = 0; index < 6; index++) {
                gl.uniform4fv(colorLocation, faceColors[index]);
                gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, index * 6 * Uint16Array.BYTES_PER_ELEMENT);  // 绘制
            }
            window.requestAnimationFrame(render)
        }
        window.requestAnimationFrame(render) // 大约每秒 60 帧
    </script>

参考资料

参考资料有:
https://www.bilibili.com/video/BV1AS411A74T
https://developer.mozilla.org/zh-CN/docs/Web/API/WebGL_API
https://bx2eq6ulg18.feishu.cn/docx/I4hTdO95qozPBOxauPlcbDfInVd?from=from_copylink
https://www.qianbo.com.cn/Tool/Text-Difference/
https://excalidraw.com/
https://avikdas.com/2020/07/08/barebones-webgl-in-75-lines-of-code.htm

标签: 原创 三维 webgl