使用vr-panorama生成一個vr全景漫遊系統(二)

發表於2019-03-02

前言

接著上一篇使用vr-panorama生成一個vr全景漫遊系統(一),這篇文章我們主要介紹vr-panorama專案中動態載入切片圖的實現。

將一張全景圖貼在球面上我們可以很容易的實現,只要在球面上使用全景圖作為紋理就可以了,但是一般來說,一張清晰的全景圖尺寸都很大,如果直接顯示整個貼到球面上,使用者可能會等待很長一段時間才能看到渲染效果,對於使用者來說體驗很不友好,所以我們需要實現全景圖的按需載入,我們首先將全景圖壓縮到一個體積比較小的尺寸,然後先渲染到球面上,然後當使用者拖動全景圖的時候我們通過計算得到應該渲染的碎片圖,然後把這些清晰的碎片圖載入到頁面上。

為了實現這個功能,我們需要思考以下幾個問題:

  • 如何在球面上渲染多張紋理圖片
  • 如何將碎片圖渲染到它應該出現的位置上
  • 如何判斷當前視野內應該載入哪些碎片圖

如何在球面上渲染多張全景圖

首先我們介紹一下三角面的概念,在threejs模型中,無論是正方體還是球體或者多面體,組成他們基本的單位都是三角形,正方體中,每一個面都是由兩個三角形組合完成的,在球體中,同樣也是通過一個個三角形組合完成的。我們就稱這每一個三角形為三角面,在官方文件中,我們可以直觀的看到每一個三角面。以球體為例,我們使用three生成球體物件的時候,需要制定橫向切割數和縱向切割數,當我們的橫向切割和縱向切割的值越大,生成的三角面也就越多,所生成的球體也就越像一個真正的球體。當我們使用紋理貼圖的時候,實際上是把圖片紋理渲染到每一個三角面上,然後組合成了完成的圖片。

所以,想要在球面上渲染多張全景圖,我們就需要讓每一個三角面使用不同的圖片作為渲染源。

首先,我們把要渲染的碎片圖新增到materials陣列中:

// glPainter.js
// 載入清晰圖
  loadSlices() {
    // 判斷如果全部的碎片圖都載入過一次就不再載入
    if(this.complate) return;
    const urls = this.slices;
    const camera = this.viewer.camera;
    if(!urls) return;
    const row = urls.length;
    const col = urls[0].length;
    // 渲染
    for(let i = 0; i < row; i++) {
      for(let j = 0; j < col; j++) {
        const index = i * col + j + 1;
          if(!this.sliceMap[`${i}-${j}`]) {
            const isInSight = utils.isInSight(i, j, camera);
            if(isInSight) {
              this.drawSlice(index, urls[i][j]);
              this.sliceMap[`${i}-${j}`] = 1;
              this.complate = this.checkComplate();
            }
          }
      }
    }
  }
複製程式碼

這裡我們通過讀取資料中的slices陣列,然後判斷碎片圖是否在當前視野(這個判斷函式我們後面再詳細說),如果在的話我們就去載入這個圖片,並新增到materials陣列中:

// glpainter.js
  // 設定材料陣列
  drawSlice(index, url) {
    let loader = new TextureLoader();
    loader.format = RGBFormat;
    loader.crossOrigin = `*`;
    // 使用全景圖片生成紋理
    loader.load(url, (texture) => {
      // 這裡可以讓紋理之間的過渡更加自然,不會出現明顯的稜角
      texture.minFilter=LinearFilter;
      texture.magFilter=LinearFilter;
      this.sphere.material[index] = new MeshBasicMaterial({
        map: texture
      });
      this.updateSliceView(index);
    });
  }
複製程式碼

現在我們要做的就是指定每一個三角面使用它對應的材料作為紋理:

  // 更新三角面uv對映
  updateSliceView(index) {
    let sliceIndex = 0;
    const {widthSegments, heightSegments, widthScale, heightScale} = this;
    for (let i = 0, l = this.sphere.geometry.faces.length; i < l; i++) {
      // 每一個三角面對應的圖片索引
      const imgIndex = utils.transIndex(i, widthSegments, heightSegments, widthScale, heightScale);
      if(imgIndex === index) {
        sliceIndex++;
        const uvs = utils.getVertexUvs(sliceIndex, widthScale, heightScale);
        if(i >= widthSegments*2*heightSegments - 3*widthSegments || i < widthSegments) {
          this.sphere.geometry.faces[i].materialIndex = index;
          this.sphere.geometry.faceVertexUvs[0][i][0].set(...uvs[0].a);
          this.sphere.geometry.faceVertexUvs[0][i][1].set(...uvs[0].b);
          this.sphere.geometry.faceVertexUvs[0][i][2].set(...uvs[0].c);
        }else {
          this.sphere.geometry.faces[i].materialIndex = index;
          this.sphere.geometry.faces[i+1].materialIndex = index;
          this.sphere.geometry.faceVertexUvs[0][i][0].set(...uvs[0].a);
          this.sphere.geometry.faceVertexUvs[0][i][1].set(...uvs[0].b);
          this.sphere.geometry.faceVertexUvs[0][i][2].set(...uvs[0].c);
          this.sphere.geometry.faceVertexUvs[0][i+1][0].set(...uvs[1].a);
          this.sphere.geometry.faceVertexUvs[0][i+1][1].set(...uvs[1].b);
          this.sphere.geometry.faceVertexUvs[0][i+1][2].set(...uvs[1].c);
          i++;
        }
      }
    }
  }
複製程式碼

每一個三角面有一個materialIndex屬性,它會自動讀取materials物件中的指定index作為當前三角面的渲染源。

這裡大家可能會有一個疑問,我們的球面被橫向切成了很多份,縱向也被切割成了很多份,而我們的全景圖碎片是按照8*4切割的,所以我們的materials陣列最多也就32張圖片,怎麼知道每一個三角面應該使用哪張圖片作為當前三角面的material呢?

其實這個是可以通過計算來得到的,我寫了一個transIndex函式來完成這個計算,在看這個函式之前我們先看一張圖:

使用vr-panorama生成一個vr全景漫遊系統(二)

這是一個橫向切割數為12,縱向切割數為6的球體的三角面構成。它的三角面總數為120,其中頂部和底部的三角面數量是12,中間的每一行三角面數量是24。然後我們再來看這個函式,應該能更好理解:

 /**
  * @description 這個函式用來計算球體每個三角面對應使用哪一張圖片作為紋理
  * 全景圖被分成 4*8 張圖片 也就是4行8列
  * 球體的三角面數量為 橫向分割數*2 + (縱向分割數-2)*橫向分割數*2
  * 如果球體的縱向分割和橫向分割正好是4和8,那麼頂部和底部的每個三角面對應一張圖片,中間每兩個相鄰的三角面共用一張圖片
  * 球體的縱向分割和橫向分割大於4和8,那麼必須是4和8的整數倍,這樣每個三角面和他左右的三角面和上下的三角面共用一張圖片
  * @param {any} i 三角面的索引(第幾個三角面)
  * @param {any} widthSegments 球體橫向切割數
  * @param {any} heightSegments 球體縱向切割數
  * @param {any} widthScale 球體橫向切割數/全景圖的橫向切割數
  * @param {any} heightScale 球體縱向切割數/全景圖的縱向切割數
  * @returns imgIndex 圖片索引
  */
 transIndex(i, widthSegments, heightSegments, widthScale, heightScale) {
    let row, col, imgIndex;
    // 第一行
    if(i < widthSegments) {
      row = 1;
      col = i+1;
    }else if(i < 3*widthSegments) {
      // 第二行
      row = parseInt((i+widthSegments)/(2*widthSegments)) + 1;
      col = parseInt((i - (row-1)*widthSegments)/2) + 1;
    }else if(i < widthSegments+2*widthSegments*(heightSegments-2)) {
      row = parseInt((i-widthSegments)/(2*widthSegments)) + 2;
      col = parseInt((i - (row-2) * 2 * widthSegments -widthSegments )/2) + 1;
    }else {
      // 最後一行
      row = parseInt((i-widthSegments)/(2*widthSegments)) + 2;
      col = parseInt( i - (row-2) * 2*widthSegments -widthSegments ) + 1;
    }
    row = Math.ceil(row/heightScale);
    col = Math.ceil(col/widthScale);
    imgIndex = (col-1) * 4 + row;
    return imgIndex;
  }
複製程式碼

如何將碎片圖渲染到它應該出現的位置上

現在,我們已經能夠為每一個三角面指定不同的材料了,但是這個時候你會發現它們組合起來的圖形並沒有像我們的預想那樣。這裡涉及到threejs中uv對映的概念。

關於uv對映,這裡推薦一篇文章,這篇文章裡介紹了立方體貼圖中uv對映的實現方式。其實,所以當我們將一整張圖片貼到球面的時候,threejs已經為我們計算好了每一個三角面uv對映的值,讓每一個三角面只渲染圖片的某一部分,然後這些三角面組合在一起,就生成了完整的圖片。

所以,我們雖然改變了每一個三角面所使用的渲染材料,但是我們並沒有改變它們的uv對映座標。文字描述可能不夠直觀,我們以上面的球面三角面為例,我們來看索引為61的三角面,它的uv對映座標為[(6/12, 2/6), (7/12, 2/6), (6/12, 1/6)],它負責渲染下圖的綠色區域:

使用vr-panorama生成一個vr全景漫遊系統(二)

根據transIndex函式,我們計算出它應該載入的碎片圖是這張:

使用vr-panorama生成一個vr全景漫遊系統(二)

這時候根據原來的uv對映座標,它渲染的就是下圖中綠色區域:

使用vr-panorama生成一個vr全景漫遊系統(二)

所以,看到問題出在哪裡了吧,現在我們要做的就是重新計算出每一個三角面的uv座標,上面這種情況是最簡單的一種情況:我們把球體的橫縱向切割數和我們的全景圖片的橫縱向切割數設成一樣,這個時候,對於頂部和底部,每一個三角面對應每一個碎片圖,中間的部分每兩個三角面共用一個碎片圖,他們的uv座標我們可以很容易計算出,但是這樣會帶來一個問題,頂部和底部由於只能利用碎片圖的一半,必然會出現圖片資訊的丟失,為了讓丟失的資訊儘可能少,我們需要將圖片切割成很多份,我個人測試,當橫向和縱向切割數都>=64的時候,丟失的資訊接近於0,這時候我們需要切割出64*64張碎片圖,顯然是不合理的,所以正常情況下,我們會有多行,多列三角面共用一張碎片圖,我們要做的就是計算出對於這一張碎片圖,每一個三角面的uv座標,下面是我寫的getVertexUvs函式(當時寫的時候可能只有我和上帝知道這段程式碼是什麼意思,現在來看,估計只有上帝知道了?):

 /**
  * @description 這個函式用來計算當前三角面和他下一個三角面的uv對映座標(兩個相鄰的三角面拼成一個矩形)
  * 比如說當前全景圖是4*8 4行8列,但是球體被分割成8*16
  * 所以某一張分割圖要被當前行4個三角面使用上半部分,被下一行的4個三角面使用下半部分(第一行和最後一行除外)
  * 第一行的話就是2個三角面使用上半部分,下一行的4個三角面使用下半部分
  * 最後一行的話就是上一行的4個三角面使用上半部分,當前行的2個三角面使用下半部分
  * 所以第一行和最後一行會有缺失
  * @param {any} index 第幾個使用當前圖形作為紋理的三角面
  * @param {any} widthScale 球體橫向分割/全景圖橫向切割
  * @param {any} heightScale 球體縱向切割/全景圖縱向切割
  * @returns 兩個三角面的uv對映座標
  */
 getVertexUvs(index, widthScale, heightScale) {
    // 兩個三角面組成的矩形的四個頂點座標
    const vectors = [
      [((index-1)%widthScale + 1)/widthScale, 1- (parseInt((index-1)/widthScale)%heightScale)/heightScale],
      [((index-1)%widthScale)/widthScale, 1- (parseInt((index-1)/widthScale)%heightScale)/heightScale],
      [((index-1)%widthScale)/widthScale, 1- (parseInt((index-1)/widthScale)%heightScale + 1)/heightScale],
      [((index-1)%widthScale + 1)/widthScale, 1- (parseInt((index-1)/widthScale)%heightScale + 1)/heightScale]
    ];
    return [
      {
        a: vectors[0],
        b: vectors[1],
        c: vectors[3]
      },
      {
        a: vectors[1],
        b: vectors[2],
        c: vectors[3]
      }
    ];
  }
複製程式碼

有興趣的同學可以研究一下這裡的邏輯,這裡不再過多介紹。

如何判斷當前視野內應該載入哪些碎片圖

最後回到一開始的isInSight函式,我們通過這個函式來判斷當前視野應該載入哪張碎片圖。先說一下實現的大概思路:

首先我們需要知道當前視野內有哪幾張碎片圖,還記得我們上一篇文章中介紹的視錐體嗎?既然這裡和我們的視野有關,當然離不開視錐體了,我們可以把問題轉換為哪些碎片圖與當前視錐體相交,如果相交,那麼這張碎片圖就在視野中。

如何判斷一個碎片圖是否和視錐體相交呢?我們知道,每一張碎片圖都有自己的2d座標,當它被渲染到球面上的時候,也有自己的3d座標,從2d座標轉換到3d座標,有沒有讓你想起經緯度呢?還是拿這張圖片為例:

使用vr-panorama生成一個vr全景漫遊系統(二)

圖中綠色的碎片圖的2d座標是[(3/8, 1/4), (4/8, 1/4), (4/8, 1/2), (3/8, 1/2)],渲染到球面上它的經度就是每個點x乘2π,緯度就是每個點的y座標乘π,通過球體的頂點計算公式,我們計算出這個碎片圖的四個點的座標,然後生成一個包圍球,判斷包圍球與視錐體是否相交。

下面是完整實現:

 /**
  * @description 這個函式用來判斷一張切圖是不是在當前視線中
  * 球體頂點計算公式 x: r*sinθ*cosφ y: r*cosθ z: r*sinθ*sinφ θ緯度 φ經度
  * 行 => 緯度  列 => 經度
  * 全景圖一共4行8列 那麼某一張圖片對應到球面上的頂點座標就可以求出來
  * 然後根據這4個頂點建立一個幾何圖形,判斷這個幾何圖形的包圍球是否與相機的視錐體相交
  * @param {any} row 當前切圖的行
  * @param {any} col 當前切圖的列
  * @param {any} camera 判斷相交的相機
  * @returns 是否在當前視線
  */
 isInSight(row, col, camera) {
    // 球體半徑
    const Radius = 10;
    // 經度 2π 分成8份, 每份是4/π
    // 維度 π 分成4份, 每份也是4/π
    const ltPoint = {
      x: Radius*Math.sin(col * Math.PI / 4) * Math.cos(row * Math.PI / 4),
      y: Radius*Math.cos(col * Math.PI / 4),
      z: Radius*Math.sin(col * Math.PI / 4) * Math.sin(row * Math.PI / 4)
    };
    const rtPoint = {
      x: Radius*Math.sin(col * Math.PI / 4) * Math.cos((row+1) * Math.PI / 4),
      y: Radius*Math.cos(col * Math.PI / 4),
      z: Radius*Math.sin(col * Math.PI / 4) * Math.sin((row+1) * Math.PI / 4)
    };
    const lbPoint = {
      x: Radius*Math.sin((col+1) * Math.PI / 4) * Math.cos(row * Math.PI / 4),
      y: Radius*Math.cos((col+1) * Math.PI / 4),
      z: Radius*Math.sin((col+1) * Math.PI / 4) * Math.sin(row * Math.PI / 4)
    };
    const rbPoint = {
      x: Radius*Math.sin((col+1) * Math.PI / 4) * Math.cos((row+1) * Math.PI / 4),
      y: Radius*Math.cos((col+1) * Math.PI / 4),
      z: Radius*Math.sin((col+1) * Math.PI / 4) * Math.sin((row+1) * Math.PI / 4)
    };

    // 建立一個幾何圖形,四個頂點分別為貼圖的四個頂點座標、
    const geometry = new Geometry();
    geometry.vertices.push(
        new Vector3( ltPoint.x, ltPoint.y, ltPoint.z ),
        new Vector3( rtPoint.x, rtPoint.y, rtPoint.z ),
        new Vector3( lbPoint.x, lbPoint.y, lbPoint.z ),
        new Vector3( rbPoint.x, rbPoint.y, rbPoint.z ),
    );
    geometry.faces.push( new Face3( 0, 1, 2 ), new Face3( 1, 2, 3 ) );

    // 然後判斷包圍球是否與視錐體相交
    const tagMesh = new Mesh(geometry);
    const off = this.isOffScreen(tagMesh, camera);
    return !off;
  }
複製程式碼

最後

至此,我們就實現了全景圖的按需載入,專案中剩下的vr眼鏡模式,除css3d的相容實現,基本上是使用threejs的相關外掛完成的,就不再詳細介紹了,有興趣的同學可以去專案地址中檢視,有問題歡迎交流,如果該專案對你有幫助,別忘了給個star哦,感謝閱讀。

相關文章