一个简单的全景模型

最近在整理以前写过的一些零散的代码,做一下知识的回顾和总结。把两年前用iOS写的一个简单的全景模型翻了出来,用WebGL重新写了一遍,权当是温故知新吧。

在做全景模型之前我们需要先了解几个知识

要做到水平360°垂直180°无死角的全景体验,我们需要搭建一个球体模型,视角放置在球心的位置,然后把拍摄到的图片贴图到球模型的内部,就可以构建起一个简单的全景体验,下图是通过Grapher生成的,可作参考

墨卡托投影(Mercator projection)

墨卡托投影,是正轴等角圆柱投影。由荷兰地图学家墨卡托(G.Mercator)于1569年创立。假想一个与地轴方向一致的圆柱切或割于地球,按等角条件,将经纬网投影到圆柱面上,将圆柱面展为平面后,即得本投影。墨卡托投影在切圆柱投影与割圆柱投影中,最早也是最常用的是切圆柱投影。

  • 在全景图像采集阶段我们需要用该投影把全景空间采集到的图像投射到一张平面图片上,作为全景信息的记录,这部分工作一般会在全景相机内部完成或者在全景图片拼接生成过程中完成,这里就不多做解释,本例暂未涉及

  • 在全景图像展示阶段我们需要用该投影把平面全景图片贴图到我们的球模型上,详细的过程可参考

  • 墨卡托投影(百度)

  • 墨卡托投影(维基)

  • 通过下面的图我们可以先做个简单了解,心理大致有个概念

    轨迹球算法

    计算机的三维世界显示类似生活中的摄影,屏幕就是一个相机,三维模型就是被摄物体。三维模型在屏幕上的投影形成我们所能看到的画面。
    我们与三维模型的交互是通过二维的计算机屏幕来完成的,我们在二维屏幕上拖拽鼠标,从而引起三维模型的变化。二维空间的变化是不能直接应用在三维空间的,因此我们需要在二维世界和三维世界搭建一个桥梁来完成整个交互过程。轨迹球就是在二维空间之外虚构一个球形曲面,使鼠标在二维空间上的移动投影到球形曲面上,再通过球形曲面的变化改变引起三维世界的变化。

    整个过程跟以前机械鼠标的轨迹球非常类似

    代码很简单,画布准备好了

    启动WebGL

    var names = ["webgl", "experimental-webgl", "webkit-3d", "moz-webgl"];
    var gl = null;
    var canvas = document.getElementById('webgl');
    for (var ii = 0; ii < names.length; ++ii) {
        try {
            gl = canvas.getContext(names[ii]);
        } catch (e) { }
        if (gl) {
            break;
    

    这里需要做一些兼容的事情,不同浏览器getContext的参数稍有不同,拿到gl之后就可以在canvas绘制3D模型了
    用法跟OpenGL一样,只不过稍有一点点差别

    构建球模型

    全景贴图之前得准备一个球模型,OpenGL的惯例我们需要用三角形对球面进行分割

    球面分割函数

     var createSphere = function (hslice, vslice) {
        var verticesSizes = new Float32Array(hslice * vslice * 3 * 2 * 5);
        var theta, fai;
        var hstep = Math.PI / hslice;
        var vstep = 2 * Math.PI / vslice;
        var index = 0;
        for (var i = 0; i < hslice; i++) {
            theta = hstep * i;
            for (var j = 0; j < vslice; j++) {
                fai = -Math.PI + vstep * j;
                // 点坐标
                var p1 = getPointTheta(theta, fai);
                var p2 = getPointTheta(theta + hstep, fai);
                var p3 = getPointTheta(theta, fai + vstep);
                var p4 = getPointTheta(theta + hstep, fai + vstep);
                // 纹理坐标
                var st1 = getSTTheta(theta, fai);
                var st2 = getSTTheta(theta + hstep, fai);
                var st3 = getSTTheta(theta, fai + vstep);
                var st4 = getSTTheta(theta + hstep, fai + vstep);
                // 上三角
                index = getVertice(verticesSizes, p1, st1, index);
                index = getVertice(verticesSizes, p2, st2, index);
                index = getVertice(verticesSizes, p3, st3, index);
                // 下三角
                index = getVertice(verticesSizes, p3, st3, index);
                index = getVertice(verticesSizes, p2, st2, index);
                index = getVertice(verticesSizes, p4, st4, index);
        return verticesSizes;
    var getPointTheta = function (theta, fai) {
        x = sinθ.sinφ
        y = cosθ
        z = sinθ.cosφ
        (θ[0,π] φ[-π,π])
        var x = Math.sin(theta) * Math.sin(fai);
        var y = Math.cos(theta);
        var z = Math.sin(theta) * Math.cos(fai);
        return { x: x, y: y, z: z };
    var getVertice = function(verticesSizes, p, st, index) {
        verticesSizes.set([p.x, p.y, p.z, st.s, st.t], index);
        return index + 5;
    

    然后我们就得到了一个三角形分割的球面

    这里的分割算法仔细想想还是有些拙劣,在两极附近三角形会比较密集,赤道附近三角形会比较稀疏,这样并不能对球面进行均匀分割,其实球面分割算法有很多种,如基于正20面体的不断分割最终得到一个球面,这种方法得到的球面三角形就会分布比较均匀,但是实现起来有点费劲,这里还是有些偷巧了

    墨卡托投影

    之前已经说过,多数的全景相机或者全景拼接软件会通过墨卡托投影的方式把三维全景信息映射到二位屏幕图片上,我们所要做的就是把二位图片还原为三维全景,因此需要用到墨卡托投影的古德曼函数

    从纬线φ和经线λ(其中λ0是地图的中央经线)推导为坐标系中的点坐标x和y

  • x = λ - λ0
  • y = ln(tanφ + secφ)
  • 因为在两极附近y的值是趋于无穷大的,因此需要做一点简单的处理

    球面纹理映射函数

    var epsilon = Math.PI * 10/180;         // 两极部分去掉10°
    var mmax = Math.PI/2 - epsilon;
    var mmaxvalue = Math.log(Math.tan(mmax) + 1.0/Math.cos(mmax))
    var ts = (Math.PI/2 - epsilon)/(Math.PI/2);
    var getSTTheta = function (theta, fai) {
        // 墨卡托坐标
        var s = 0.5 - (fai) / (2 * Math.PI);
        // [-π/2, π/2] * ts
        var mtheta = -(theta - Math.PI / 2) * ts;
        var t = Math.log(Math.tan(mtheta) + 1.0 / Math.cos(mtheta))
        t = 0.5 + 0.5 * t / mmaxvalue;
        return { s: s, t: t };
    

    有了球面的三角形分割和球面纹理映射我们就得到了WebGL可用的点坐标和纹理坐标,接下来就可以进行球面绘制了

    顶点着色器&片元着色器

    有了顶点坐标和纹理坐标之后我们就需要为这些点和纹理建立一个映射关系,就是描述一下如何把我们需要的纹理绘制到点坐标指定位置

    这里就需要用到WebGL提供的顶点着色器和片元着色器(也叫像素着色器)。

    简单来讲,着色器(Shader)是用来实现图像渲染的,用来替代固定渲染管线的可编辑程序。其中顶点着色器主要负责顶点的几何关系等的运算,片元着色器主要负责片源颜色等的计算。再通俗点说,顶点着色器是用来打线稿的,片元着色器是用来上色的。

    着色器脚本

    接下来我们看下我们的球面模型的顶点着色器和片元着色器

    // 顶点着色器
    attribute vec4 position;
    attribute vec2 texcoord;
    uniform mat4 modelViewProjectionMatrix;
    varying vec2 texcoordVarying;
    varying vec4 positionVarying;
    void main()
        positionVarying = position;
        texcoordVarying = texcoord;
        // 控制模型变换
        vec4 positionV = modelViewProjectionMatrix * position;  
        // 在原点位置变换之后重新定位新的原点位置,并以此计算新的顶点相对位置,给片源着色器用
        positionVarying = positionV - modelViewProjectionMatrix * vec4(0.0,0.0,0.0,1.0);
        gl_Position = positionV;
    // 片元着色器
    varying lowp vec2 texcoordVarying;
    varying lowp vec4 positionVarying;
    uniform sampler2D colorMap;
    void main()
        // 采集纹理
        lowp vec4 textureColor = texture2D(colorMap,texcoordVarying); 
        // 来自顶点着色器的顶点位置,把球面刨开,只展示半球
        if (positionVarying.z < 0.0) {
            discard;
        gl_FragColor = textureColor;
    

    编译连接着色器脚本

    写好着色脚本之后我们需要对脚本进行编译和连接,之后才能使用

    var program = this.createProgram(gl, vshader, fshader);
    if (!program) {
        console.log('Failed to create program');
        return false;
    gl.useProgram(program);
    gl.program = program;
    createProgram(gl, vshader, fshader) {
        // Create shader object
        var vertexShader = this.loadShader(gl, gl.VERTEX_SHADER, vshader);
        var fragmentShader = this.loadShader(gl, gl.FRAGMENT_SHADER, fshader);
        if (!vertexShader || !fragmentShader) {
            return null;
        // Create a program object
        var program = gl.createProgram();
        if (!program) {
            return null;
        // Attach the shader objects
        gl.attachShader(program, vertexShader);
        gl.attachShader(program, fragmentShader);
        // Link the program object
        gl.linkProgram(program);
        // Check the result of linking
        var linked = gl.getProgramParameter(program, gl.LINK_STATUS);
        if (!linked) {
            var error = gl.getProgramInfoLog(program);
            console.log('Failed to link program: ' + error);
            gl.deleteProgram(program);
            gl.deleteShader(fragmentShader);
            gl.deleteShader(vertexShader);
            return null;
        return program;
    loadShader(gl, type, source) {
        // create shader object
        var shader = gl.createShader(type);
        if (shader == null) {
            console.log('unable to create shader');
            return null;
        // Set the shader program
        gl.shaderSource(shader, source);
        // Compile the shader
        gl.compileShader(shader);
        // Check the result of compilation
        var compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
        if (!compiled) {
            var error = gl.getShaderInfoLog(shader);
            console.log('Failed to compile shader: ' + error);
            gl.deleteShader(shader);
            return null;
        return shader;
    

    加载纹理数据

    之前的纹理坐标已经配置好了,这个时候要把需要的纹理数据,也就是我们的全景图载入到程序中。这里需要一点点技巧

    非二次幂纹理的处理

    二次幂纹理就是纹理图像的长和宽都为2的整数次幂,这样的纹理可以得到更好的处理速度和性能保证,但是我们拿到的全景图片不太可能做到保证每一张都能做到规整的二次幂图,以此我们需要做一点调整
    对非二次幂纹理进行二次幂重绘

    纹理图像翻转

    由于图像的坐标系(零点在左上角)和WebGL的坐标系(零点在左下角)存在差异,因此有些时候需要对图像进行一定的翻转

    Web图像异步加载

    Web图像下载会有一定的延时,因此需要做好异步处理

    isPowerOfTwo(x) {
        return (x & (x - 1)) == 0;
    nextHighestPowerOfTwo(x) {
        for (var i = 1; i < 32; i <<= 1) {
            x = x | x >> i;
        return x + 1;
    loadImageTexture(gl, url, callback) {
        var image = new Image();
        var self = this;
        image.onload = function() {
            if (!self.isPowerOfTwo(image.width) || !self.isPowerOfTwo(image.height)) {
                // Scale up the texture to the next highest power of two dimensions.
                var canvas = document.createElement("canvas");
                canvas.width = self.nextHighestPowerOfTwo(image.width);
                canvas.height = self.nextHighestPowerOfTwo(image.height);
                var ctx = canvas.getContext("2d");
                ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
                image = canvas;
            var texture = gl.createTexture();
            // 绑定纹理
            gl.bindTexture(gl.TEXTURE_2D, texture)
            // 对纹理图像进行y轴翻转
            gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image);
            // 配置纹理参数
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
            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);
            // 生成mipmap
            gl.generateMipmap(gl.TEXTURE_2D);
            gl.bindTexture(gl.TEXTURE_2D, null);
            callback(texture);
        image.src = url;
    

    有了顶点和纹理坐标、着色脚本、全景纹理数据我们就可以进行绘图了,当然这个过程中还会涉及到投影变换和模型变换,这个我们接下来再说
    先看看简单的绘图脚本

    draw: function () {
        var gl = this.gl;
        var position = gl.getAttribLocation(gl.program, 'position');
        var texcoord = gl.getAttribLocation(gl.program, 'texcoord');
        var colorMap = gl.getUniformLocation(gl.program, 'colorMap');
        var modelViewProjectionMatrix = gl.getUniformLocation(gl.program, 'modelViewProjectionMatrix');
        var vertexBuffer = gl.createBuffer();
        if (!vertexBuffer) {
            return;
        var FSIZE = this.verticesSizes.BYTES_PER_ELEMENT;
        gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, this.verticesSizes, gl.STATIC_DRAW);
        gl.vertexAttribPointer(position, 3, gl.FLOAT, false, FSIZE * 5, 0);
        gl.enableVertexAttribArray(position);
        gl.vertexAttribPointer(texcoord, 2, gl.FLOAT, false, FSIZE * 5, FSIZE * 3);
        gl.enableVertexAttribArray(texcoord);
        // 开启0号纹理单元
        gl.activeTexture(gl.TEXTURE0);
        // 绑定纹理
        gl.bindTexture(gl.TEXTURE_2D, this.texture);
        // 绘图
        gl.uniform1i(colorMap, 0);
        gl.uniformMatrix4fv(modelViewProjectionMatrix, false, new Float32Array(WebGLModelManager.modelViewProjectionMatrix().m));
        gl.disable(gl.BLEND);
        gl.drawArrays(gl.TRIANGLES, 0, this.verticesSizes.length/5);
        gl.deleteBuffer(vertexBuffer);
        // console.log("draw");
    

    为了保证我们绘制的三维模型的视觉真实性,我们需要用到透视投影

    具体的原理这里不做过多解释,它的目的就是为了保证我们绘制的模型看起来更加真实,比如近处的物体看起来会比较大,远处的物体看起来会比较小

    透视投影的矩阵变换为

    static makePerspective(fovyRadians, aspect, nearZ, farZ) {
        var cotan = 1.0 / Math.tan(fovyRadians / 2.0);
        var m = new Matrix4();
        m.m = [ cotan/aspect, 0.0, 0.0, 0.0,
                0.0, cotan, 0.0, 0.0,
                0.0, 0.0, (farZ + nearZ) / (nearZ - farZ), -1.0,
                0.0, 0.0, (2.0 * farZ * nearZ) / (nearZ - farZ), 0.0 ];
        return m;
    updateProjectionMatrix: function() {
        var scale =  this.trackball.degreeScale();
        let canvas = this.$refs.webgl;
        var width = canvas.clientWidth;
        var height = canvas.clientHeight;
        var ratio = width/height;
        WebGLModelManager.projectionMatrix = Matrix4.makePerspective((50.0 - 40 * (scale -1)) * (Math.PI / 180), ratio, 1.0, 1000);
    

    其实到这里我们的全景图就可以展示的比较完美了,但是你还是只能看到一个角度,没法旋转,拖拽,调整视角,如果要把交互加上去就得用到之前提过的轨迹球算法。哎,又是一个算法,好难描述啊……

    轨迹球算法

    要把这个算法解释清楚确实有点费劲,主要空间感太弱,有点说不清楚,找到一个官方的解释,觉得描述的非常准确简洁

    Object Mouse Trackball

    我们在屏幕外的空间中虚构一个半球面,在半球面的外面连接一个平滑曲面,如下图

    如此我们就可以把鼠标在屏幕上的坐标(x,y)映射到我们虚拟的空间上(x,y,z)

    我们记录鼠标的起始位置(x0,y0)->(x0,y0,z0),鼠标的终止位置(x1,y1)->(x1,y1,z1),这样我们就得到两组向量V0,V1,向量的夹角就是我们轨迹球的旋转角度,向量构成的平面法向量就是旋转轴,简单表达为

    虚拟空间函数

    z(x, y) = sqrt(r * r - (x * x + y * y)) x * x + y * y <= r * r/2

    z(x, y) = r * r/2/sqrt(x * x + y * y)

    记录鼠标位置并转化为空间坐标

    V1 = (x0, y0, z(x0, y0))

    V2 = (x1, y1, z(x1, y1))

    V1 = V1/|V1|

    V2 = V2/|V2|

    计算向量叉积得到平面法向量

    N = V1 X V2

    计算向量点积得到向量夹角

    θ = arccosV1.V2

    以向量N为旋转轴旋转θ角度就得到了我们模型的旋转矩阵

    表达能力实在有限,只能这么简单描述一下了

    我们绘图中需要做的就是把刚刚计算得到的旋转变换应用到模型上就可以了

    updateModelMatrix: function() {
        WebGLModelManager.push();
        WebGLModelManager.multiplyMatrix4(Matrix4.makeTranslation(0, 0, -1));
        // 轨迹球旋转
        WebGLModelManager.multiplyMatrix4(this.trackball.rotationMatrix4());
        WebGLModelManager.updateModelViewMatrix();