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

前言

接着上一篇使用vr-panorama生成一個vr全景漫遊系統(一),這篇文章咱們主要介紹vr-panorama項目中動態加載切片圖的實現。css

將一張全景圖貼在球面上咱們能夠很容易的實現,只要在球面上使用全景圖做爲紋理就能夠了,可是通常來講,一張清晰的全景圖尺寸都很大,若是直接顯示整個貼到球面上,用戶可能會等待很長一段時間才能看到渲染效果,對於用戶來講體驗很不友好,因此咱們須要實現全景圖的按需加載,咱們首先將全景圖壓縮到一個體積比較小的尺寸,而後先渲染到球面上,而後當用戶拖動全景圖的時候咱們經過計算獲得應該渲染的碎片圖,而後把這些清晰的碎片圖加載到頁面上。html

爲了實現這個功能,咱們須要思考如下幾個問題:css3

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

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

首先咱們介紹一下三角面的概念,在threejs模型中,不管是正方體仍是球體或者多面體,組成他們基本的單位都是三角形,正方體中,每個面都是由兩個三角形組合完成的,在球體中,一樣也是經過一個個三角形組合完成的。咱們就稱這每個三角形爲三角面,在官方文檔中,咱們能夠直觀的看到每個三角面。以球體爲例,咱們使用three生成球體對象的時候,須要制定橫向切割數和縱向切割數,當咱們的橫向切割和縱向切割的值越大,生成的三角面也就越多,所生成的球體也就越像一個真正的球體。當咱們使用紋理貼圖的時候,其實是把圖片紋理渲染到每個三角面上,而後組合成了完成的圖片。git

因此,想要在球面上渲染多張全景圖,咱們就須要讓每個三角面使用不一樣的圖片做爲渲染源。github

首先,咱們把要渲染的碎片圖添加到materials數組中:web

// 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呢?webgl

其實這個是能夠經過計算來獲得的,我寫了一個transIndex函數來完成這個計算,在看這個函數以前咱們先看一張圖:

這是一個橫向切割數爲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)],它負責渲染下圖的綠色區域:

根據transIndex函數,咱們計算出它應該加載的碎片圖是這張:

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

因此,看到問題出在哪裏了吧,如今咱們要作的就是從新計算出每個三角面的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座標,有沒有讓你想起經緯度呢?仍是拿這張圖片爲例:

圖中綠色的碎片圖的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哦,感謝閱讀。

相關文章
相關標籤/搜索