首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

M · 模型的位姿矩阵

对于图形引擎而言,每一个物体都会涉及到一个局部坐标系和一个世界坐标系。我们在局部坐标系下对物体建模,描述物体各个组成部分之间的相对关系。 在世界坐标系下对场景建模,描述各个物体应该以什么姿势摆放在哪里。模型的位姿矩阵(Model Matrix) 建立了两个坐标系之间的联系,通过对物体进行平移、旋转、缩放的变换, 将物体放置到场景中合适的位置上。

点击画布,移动三角形

本质上位姿矩阵(下文中简称 M 矩阵)描述的就是如何移动一个物体,如右侧的例程所示, 点击画布就可以将三角形移动到点击的位置上。本节中,我们将从这个示例出发,介绍 M 矩阵的相关概念和代码实现。

1. 在顶点渲染器中完成物体的移动

渲染物体的通用套路是,在局部坐标系下对物体建模,渲染之前先把建模的顶点坐标通过 VBO 搬运到 GPU 中, 并构造 VAO 将局部坐标系下的顶点映射到顶点渲染器中。 由顶点渲染器负责将 M 矩阵左乘到顶点坐标上,得到世界坐标系下的顶点。下面左侧是例程中使用的顶点渲染器。

左侧第 2 行定义了一个 vec4 类型的变量 pos,用 in 进行修饰表示可以通过 VBO 和 VAO 搬运到 GPU 中并映射进来的一个顶点。 在计算机中,我们常在射影空间下用四维的齐次坐标描述空间中的一个点 \(\boldsymbol{p} = \begin{bmatrix} kx, ky, kz, k \end{bmatrix}\)。\(k\) 是一个比例系数,它取任何非零的值描述的都是同一个点, 所以一般情况下都取 1,即,\(\boldsymbol{p} = \begin{bmatrix} x, y, z, 1 \end{bmatrix}\)。

下面右侧是例程的 js 代码片段,它在一个数组里面顺序定义了三个位于 \(x-y\) 平面上的点。然后通过封装之后的函数 bindArrayBuffer 将顶点坐标的搬运到 GPU 中。 最后用 activateAttribute 将各点的 xyz 坐标映射到顶点渲染器中定义的 "pos" 上。而顶点渲染器中 "pos" 是一个四维的向量,webgl 会将缺少的那一维比例系数置为 1。

        #version 300 es
        in vec4 pos;
        uniform mat4 modelMatrix;
        void main() {
            gl_Position = modelMatrix * pos;  
        }
        //                x,   y,   z
        let positions = [ 0,   0,   0,
                          1,   0.5, 0,
                          0,   0.5, 0 ];
        let vbo = render.bindArrayBuffer(new Float32Array(positions));
        let vao = render.activateAttribute("pos", 3, "float");

顶点渲染器应用 M 矩阵移动物体的关键是上面左侧代码中第 3 行定义的 mat4 类型的矩阵 modelMatrix。 该变量用 uniform 修饰,在渲染物体之前,我们可以用 webgl 的接口 gl.uniformMatrix4fv 修改它,告知顶点渲染器,物体的位置和姿态。 渲染时就在 main 函数中,将该矩阵左乘局部坐标系下的点坐标 pos,得到世界坐标系下的点坐标 gl_Position

        let mat = new XiaoTu.Math.Matrix4();
        render.clearColor(1, 0, 0);
        render.uniform4fv("uPixelColor", [0, 1, 0, 1]);
        render.uniformMatrix4fv("modelMatrix", mat.elements);
        render.drawArrays(vao, gl.TRIANGLES);

右侧是进行渲染的 js 代码片段。首先定义了一个 \(4 \times 4\) 的矩阵对象 mat。 类型 Matrix4 是一个矩阵的封装,它实际上就是一个 Float32Array,按列存储矩阵中的元素。 数组中第 i + j * nrows 个元素,对应矩阵中第 \(i\) 行第 \(j\) 列的元素。 矩阵还有一种按行存储的方式,这里之所以要按照列存储,是为了与 OpenGL 的存储方式保持一致。 为了方便,我们在 Matrix4 的构造函数中将它初始化成单位矩阵,表示物体没有任何移动。

接下来的 4 行语句依次完成 ① 将画布背景刷成红色 ② 设置片元着色器中变量 uPixelColor 将要绘制的三角形渲染成绿色 ③ 将单位矩阵搬运到顶点渲染器的 modelMatrix 变量中 ④ 指示 WebGL 上下文进行一次三角形渲染。如此就可以看到例程的初始状态,在一个红色的画布上放置着一个绿色的三角形。下面我们再来看看如何响应鼠标的点击事件,移动三角形。

        render.domElement.onmousedown = function(ev) {
            let x = 2 * ev.offsetX / render.width() - 1.0;
            let y = 1.0 - 2 * ev.offsetY / render.height();
            mat.set(0, 3, x);
            mat.set(1, 3, y);

            render.clearColor(1, 0, 0);
            render.uniform4fv("uPixelColor", [0, 1, 0, 1]);
            render.uniformMatrix4fv("modelMatrix", mat.elements);
            render.drawArrays(vao, gl.TRIANGLES);
        }

2. 平移三角形

为了能够响应鼠标的点击事件,我们注册了 onmousedown 事件的回调函数,如右侧的代码所示。每当我们在画布上点击一次鼠标,浏览器都会调用一次该函数,完成一次渲染操作。

该函数有一个输入参数 ev,它详细记录了一次鼠标事件的各种信息。其中 offsetX 和 offsetY 是鼠标点击位置在画布中的像素坐标。我们先在第 2,3 行中做一次坐标映射,得到 webgl 的渲染坐标。

如左图所示,像素坐标系的原点在画布的左上角,x 轴水平向右,y 轴竖直向下。而 WebGL 的渲染坐标系原点在画布的中心,x 轴水平向右,y 轴竖直向上。 像素坐标都是离散的整数,而且其 x 轴的值域是 \([0, \text{width}]\), y 轴的值域是 \([0, \text{height}]\)。 而 WebGL 渲染坐标则是连续的实数,并且 x-y 轴的值域都是 \([-1.0, 1.0]\)。

在接下来的第 4,5 行中,我们将转换后的渲染坐标写到 M 矩阵最后一列的前两行中。M 矩阵是一个 \(4 \times 4\) 的矩阵, 它的详细描述可以参考刚体运动与齐次变换。 可以写成分块的形式 \(M = \begin{bmatrix} \boldsymbol{R}_{3\times 3} & \boldsymbol{t}_{3 \times 1} \\ \boldsymbol{0} & 1 \end{bmatrix}\)。 其中矩阵块 \(\boldsymbol{R}_{3\times 3}\) 是一个 \(3 \times 3\) 的旋转矩阵,描述的是物体在空间中的姿态。 如果是一个单位矩阵 \(\boldsymbol{R}_{3\times 3} = \begin{bmatrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{bmatrix}\),则表示物体在空间中没有旋转运动。 三维向量 \(\boldsymbol{t}_{3 \times 1} = \begin{bmatrix} x \\ y \\ z \end{bmatrix}\) 描述的就是物体在空间中的位置。在这里我们只是平移三角形到鼠标点击的位置上, 所以不需要修改旋转矩阵,让它保持单位阵即可,只修改了平移向量中 x,y 的坐标值。在函数的最后,刷新背景色,设置三角形颜色,搬运 M 矩阵到顶点渲染器的 modelMatrix 变量中, 通知 WebGL 上下文完成一帧渲染。就可以看到平移三角形的效果。

        class Object3D {
            static #instance_count = 0;
            #id;
            constructor() {
                Object3D.prototype.isObject3D = true;
                this.#id = Object3D.#instance_count;
                Object3D.#instance_count++;

                this.pose = new Math.Matrix4();
                this.attributes = {};

                this.render = null;
                this.vao = null;
            }
            get id() { return this.#id; }

3. 封装 Object3D

现在我们专门封装一个数据类型 Object3D,用来管理物体的 vbo, vao, 位姿矩阵等各个渲染要素。如右侧的代码片段所示。

首先我们希望有一个唯一的 id 来区分各个 Object3D 的实例,这样未来我们构建复杂的渲染场景时可能会方便一些。 所以我们定义了两个私有的成员 #instance_count#id, 其中 #instance_count 还是类的静态成员。 在构造函数中,将 #instance_count 赋值给 #id 之后自增。 这样 #instance_count 可以统计构造的实例数量,并且每个实例的 #id 都是不一样的。

然后用成员变量 pose 来记录物体的位置和姿态,也就是模型的位姿矩阵。字段 attribute 是一个字典,用来记录物体的各种属性, 比如刚才例程中三角形的三个顶点坐标。未来随着 Shader 的功能越来越复杂,可能还会增加顶点的颜色、法向量等属性。 各个属性的键都应当与顶点渲染器(Vetex Shader)中用 in 修饰的变量名保持一致。这样方便我们把具体的数据搬运到 GPU 中构造 VBO 对象。 为了有效管理物体的各种属性,我们还专门封装了一个数据结构 Attribute读者有兴趣可以展开下面的代码看看,这里不再详细解释。

        class Attribute {
            /**
             * 构造一个 attribute,渲染时对应一个 vbo
             *
             * @param {Array|TypedArray} array 数据列表
             * @param {int} item_size 每项的元素数量
             * @param {string} type 数据类型 float? int?
             */
            constructor(array, item_size, type)
            {
                if (!type in Attribute.creator)
                    throw TypeError(`Attribute: 不支持的数据类型 ${type}`);
                this.type = type;
        
                if (!ArrayBuffer.isView(array))
                    this.array = Attribute.creator[type](array);
                else
                    this.array = array;
        
        		this.item_size = item_size;
        		this.count = array.length / item_size;
                this.length = array.length;
                this.vbo = null;
            }
            static creator = {
                float: function(array) {
                    return new Float32Array(array);
        }   }   }

最后的字段 render 和 vao 用于记录渲染该物体的 WebGL2Renderer 和 VAO 对象。重要的是字段 vao,一旦我们把属性数据搬进 GPU 并构建了 VAO 对象之后,是不需要重复构建的。 我们只需要拿着这个 vao 找 render 进行渲染就行了。

        // WebGL2Renderer 新增函数
        drawObject3D(obj, pose) {
            if (null === obj.render)
                obj.render = this;
            if (obj.render !== this)
                throw ReferenceError("渲染器错误!!!");

我们给 WebGL2Renderer 新增了一个函数 drawObject3D 用于直接渲染一个 Object3D 对象。如左侧的代码片段所示,该函数有两个输入参数。其中 obj 就是渲染对象, pose 则是该对象的 M 矩阵。

虽然我们在 Object3D 中保存了一个姿态矩阵,但这里仍然需要传递一个 M 矩阵的参数。 因为 Object3D 中的姿态矩阵是为后面渲染复杂的多刚体场景准备的,我们需要根据刚体之间的相对位姿关系, 求解出被渲染刚体的 M 矩阵,然后调用该函数完成渲染。

我们需要重点检查一下 obj.vao 这个字段。如果它为空,说明还没有渲染过该物体,它的属性数据也还没搬运到 GPU 中。所以我们先构造一个 VAO 对象。

            if (null == obj.vao) {
                obj.vao = this.gl.createVertexArray();
                this.gl.bindVertexArray(obj.vao);

并在一个 for 循环中为每个属性构建一个 vbo,这里需要保证属性的名称 attr_name 与顶点渲染器中的变量名字对应上。 这样我们就能比较方便的在下面的 13 行中找到对应的变量,完成 VAO 的配置工作。

点击画布,移动三角形

                for (const attr_name in obj.attributes) {
                   let attr = obj.attributes[attr_name];
                   attr.vbo = this.bindArrayBuffer(attr.array);
                   let location = this.gl.getAttribLocation(this.program, attr_name);
                   this.gl.vertexAttribPointer(location, attr.item_size,
                                               _gl_type_map_[attr.type], false, 0, 0);
                   this.gl.enableVertexAttribArray(location);
           }   }

在有 VAO 对象的情况下,我们就先更新模型的 M 矩阵,再通过 bindVertexArray 接口切换到 obj.vao 上,最后就可以调用 drawArrays 函数完成渲染。 右侧的例程就是用 drawObject3D 渲染出来的。

            this.uniformMatrix4fv("modelMatrix", pose.elements);
            this.gl.bindVertexArray(obj.vao);
            this.gl.drawArrays(this.gl.TRIANGLES, 0, obj.attributes.pos.count);
        }

4. 空间中旋转的立方体

大多数时候,我们渲染的物体都是由若干个三角形拼接出来的。比如我们想要渲染一个彩色的立方体,给每一个面指定一个颜色。 就可以通过如下的代码实现。

需要注意的是,一个平面实际上是有两个面的,我们应当按照逆时针的方向给出各个顶点的坐标。这样按照右手坐标系,立方体的各个面的法向量都是向外的, OpenGL 才能正确的判定各个面的遮挡关系。此外为了处理遮挡关系,我们还对 WebGL2Renderer 的成员函数 clearColor 做了一点修改,如下代码中第3,6行, 开启了深度检测,并清除深度缓冲区。否则我们就会看到立方体里面,如下面右图所示,这是不合理的。

        clearColor(r = 0.0, g = 0.0, b = 0.0, a = 1.0) {
            let gl = this.gl;
            gl.enable(gl.DEPTH_TEST);
            gl.clearColor(r, g, b, a);
            gl.clear(gl.COLOR_BUFFER_BIT);
            gl.clear(gl.DEPTH_BUFFER_BIT);
        }

我们在例程的最后定义了一个函数 doRender(),如下第 8 行所示, 该函数运行结束之后,会通过浏览器的接口 requestAnimationFrame 再次调用一次该函数,从而实现动画的效果。在第 6 行中,我们调用了一个在 Matrix4 中封装的函数, 该函数通过罗德里格斯公式,实现了指定任意旋转轴转动一定角度的旋转矩阵。 通过修改转轴只绕着 x 轴或者 y 轴转动立方体,我们会看到它是顺时针转动的。 查阅了一些资料之后,确认这个绘图窗口(ClipSpace)是一个左手坐标系。 如下面中间的图所示。后面我们会通过相机的视角矩阵来保证图形引擎是右手坐标系的。

        let rad = 0;
        let rotationSpeed = 0.001;
        function doRender() {
            render.clearColor(0, 0, 0);
            rad += rotationSpeed;
            obj.pose.setRotation(rad, 1, 1, 1);
            render.drawObject3D(obj, obj.pose);
            requestAnimationFrame(doRender);
        }
        doRender();

5. 完




Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1