一個簡單的全景模型

最近在整理以前寫過的一些零散的代碼,做一下知識的回顧和總結。把兩年前用iOS寫的一個簡單的全景模型翻了出來,用WebGL重新寫了一遍,權當是溫故知新吧。

前言

在做全景模型之前我們需要先了解幾個知識

球模型

要做到水平360°垂直180°無死角的全景體驗,我們需要搭建一個球體模型,視角放置在球心的位置,然后把拍攝到的圖片貼圖到球模型的內部,就可以構建起一個簡單的全景體驗,下圖是通過Grapher生成的,可作參考

球模型

墨卡托投影(Mercator projection)

墨卡托投影,是正軸等角圓柱投影。由荷蘭地圖學家墨卡托(G.Mercator)于1569年創立。假想一個與地軸方向一致的圓柱切或割于地球,按等角條件,將經緯網投影到圓柱面上,將圓柱面展為平面后,即得本投影。墨卡托投影在切圓柱投影與割圓柱投影中,最早也是最常用的是切圓柱投影。

  • 在全景圖像采集階段我們需要用該投影把全景空間采集到的圖像投射到一張平面圖片上,作為全景信息的記錄,這部分工作一般會在全景相機內部完成或者在全景圖片拼接生成過程中完成,這里就不多做解釋,本例暫未涉及

  • 在全景圖像展示階段我們需要用該投影把平面全景圖片貼圖到我們的球模型上,詳細的過程可參考

通過下面的圖我們可以先做個簡單了解,心理大致有個概念

墨卡托投影

軌跡球算法

計算機的三維世界顯示類似生活中的攝影,屏幕就是一個相機,三維模型就是被攝物體。三維模型在屏幕上的投影形成我們所能看到的畫面。
我們與三維模型的交互是通過二維的計算機屏幕來完成的,我們在二維屏幕上拖拽鼠標,從而引起三維模型的變化。二維空間的變化是不能直接應用在三維空間的,因此我們需要在二維世界和三維世界搭建一個橋梁來完成整個交互過程。軌跡球就是在二維空間之外虛構一個球形曲面,使鼠標在二維空間上的移動投影到球形曲面上,再通過球形曲面的變化改變引起三維世界的變化。

整個過程跟以前機械鼠標的軌跡球非常類似

軌跡球

WebGL環境搭建

有了前面的幾點知識,接下來的工作會比較容易切入。

首先我們得準備一套能在網頁上運行的3D開發環境,這里選擇了WebGL,已經非常成熟,而且被大多數瀏覽器兼容。

準備畫板

首先WebGL需要一個能夠繪制3D模型的畫布

<canvas class="canvas" id="webgl"></canvas>

代碼很簡單,畫布準備好了

啟動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的慣例我們需要用三角形對球面進行分割

球坐標系

球坐標系

球面分割在笛卡爾坐標系中完成還是有點困難的,我們把他轉換到球坐標系中就容易的多了

設定球的半徑為單位1,則

  • x = sinθ.sinφ
  • y = cosθ
  • z = sinθ.cosφ
  • θ[0,π]
  • φ[-π,π]

球面分割函數

 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) {
    --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();
    WebGLModelManager.pop();
}

至此,我們的全景模型就搭建完成了,可以通過鼠標調整觀察視角瀏覽全景圖片

全景模型,圖片資源來自理光景達全景相機

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,702評論 6 534
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,615評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,606評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,044評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,826評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,227評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,307評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,447評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,992評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,807評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,001評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,550評論 5 361
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,243評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,667評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,930評論 1 287
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,709評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,996評論 2 374

推薦閱讀更多精彩內容

  • 1 序: 很多新接觸GIS的人員對地圖投影以及坐標系統很難理解,甚至做GIS開發做了好幾年的人也有這方面的疑惑,地...
    三維GIS那點事_王躍軍閱讀 17,457評論 3 43
  • WebGL從2012年開始接觸,后面因為開始專注前端其他方面的事情,慢慢地就把它給遺忘。最近前端開始又流行起繪畫制...
    我不是傳哥閱讀 4,119評論 1 22
  • 教程 OpenGL ES實踐教程1-Demo01-AVPlayerOpenGL ES實踐教程2-Demo02-攝像...
    落影loyinglin閱讀 12,747評論 24 60
  • 愛情是酒, 喝完就不再有。 感情是紐, 斷了就不會太長久。 不管是酒還是紐, 悲傷離合總是有。 敞開心,放開手, ...
    會走貓步的魚閱讀 163評論 0 3
  • /文:灰色藍 /圖:來自網絡。 春季的來臨,天氣愈漸暖和,每日的去家附近爬山便成了日常,雖說是只座很小很小的山但由...
    灰色藍閱讀 289評論 0 2