最近在整理以前寫過的一些零散的代碼,做一下知識的回顧和總結。把兩年前用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);
}
其實到這里我們的全景圖就可以展示的比較完美了,但是你還是只能看到一個角度,沒法旋轉,拖拽,調整視角,如果要把交互加上去就得用到之前提過的軌跡球算法。哎,又是一個算法,好難描述啊……
軌跡球算法
要把這個算法解釋清楚確實有點費勁,主要空間感太弱,有點說不清楚,找到一個官方的解釋,覺得描述的非常準確簡潔
我們在屏幕外的空間中虛構一個半球面,在半球面的外面連接一個平滑曲面,如下圖
函數表達為
如此我們就可以把鼠標在屏幕上的坐標(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();
}
至此,我們的全景模型就搭建完成了,可以通過鼠標調整觀察視角瀏覽全景圖片