Three.js系列: 在元宇宙看電影,享受 VR 視覺盛宴

2022-07-11 15:01:29

本文 gihtub 地址: https://github.com/hua1995116/Fly-Three.js

最近元宇宙的概念很火,並且受到疫情的影響,我們的出行總是受限,電影院也總是關門,但是在家裡又沒有看大片的氛圍,這個時候我們就可以通過自己來造一個宇宙,並在 VR 裝置(Oculus 、cardboard)中來觀看。

今天我打算用 Three.js 來實現個人 VR 電影展廳,整個過程非常的簡單,哪怕不會程式設計都可以輕易掌握。

想要頂級的視覺盛宴,最重要的肯定是得要一塊大螢幕,首先我們就先來實現一塊大螢幕。

大螢幕的實現主要有兩種幾何體,一種是 PlaneGeometry 和 BoxGeometry,一個是平面,一個是六面體。為了使得螢幕更加有立體感,我選擇了 BoxGeometry。

老樣子,在新增物體之前,我們先要初始化我們的相機、場景和燈光等一些基礎的元件。

const scene = new THREE.Scene();

// 相機
const camera = new THREE.PerspectiveCamera(
    75,
    sizes.width / sizes.height,
    0.1,
    1000
)
camera.position.x = -5
camera.position.y = 5
camera.position.z = 5
scene.add(camera);

// 新增光照
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
scene.add(ambientLight)

const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5)
directionalLight.position.set(2, 2, -1)

scene.add(directionalLight)

// 控制器
const controls = new OrbitControls(camera, canvas);
scene.add(camera);

然後來寫我們的核心程式碼,建立一個 5 * 5 的超薄長方體

const geometry = new THREE.BoxGeometry(5, 5, 0.2);
const cubeMaterial = new THREE.MeshStandardMaterial({
    color: '#ff0000'
});
const cubeMesh = new THREE.Mesh(geometry, cubeMaterial);
scene.add(cubeMesh);

效果如下:

然後緊接著加入我們的視訊內容,想要把視訊放入到3d場景中,需要用到兩樣東西,一個是 html 的 video 標籤,另一個是 Three.js 中的視訊紋理 VideoTexture

第一步將視訊標籤放入到 html 中,並設定自定播放以及不讓他顯示在螢幕中。

...
<canvas class="webgl"></canvas>
<video 
  id="video"
  src="./pikachu.mp4"
  playsinline
  webkit-playsinline
  autoplay
  loop
  style="display:none"
  ></video>
...

第二步,獲取到 video 標籤的內容將它傳給 VideoTexture,並且紋理賦給我們的材質。

+const video = document.getElementById( 'video' );
+const texture = new THREE.VideoTexture( video );

const geometry = new THREE.BoxGeometry(5, 5, 0.2);
const cubeMaterial = new THREE.MeshStandardMaterial({
-    color: '#ff0000'
+    map: texture
});
const cubeMesh = new THREE.Mesh(geometry, cubeMaterial);
scene.add(cubeMesh);

我們看到皮神明顯被拉伸了,這裡就出現了一個問題就是紋理的拉伸。這也很好理解,我們的螢幕是 1 : 1 的,但是我們的視訊卻是 16:9 的。想要解決其實也很容易,要麼就是讓我們的螢幕大小更改,要麼就是讓我們的視訊紋理渲染的時候更改比例。

第一種方案很簡單

通過修改幾何體的形狀(也及時我們顯示器的比例)

const geometry = new THREE.BoxGeometry(8, 4.5, 0.2);
const cubeMaterial = new THREE.MeshStandardMaterial({
    map: texture
});
const cubeMesh = new THREE.Mesh(geometry, cubeMaterial);
scene.add(cubeMesh);

第二種方案稍微有點複雜,需要知道一定的紋理貼圖相關的知識

圖1-1

首先我們先要知道紋理座標是由 u 和 v 兩個方向組成,並且取值都為 0 - 1。通過在 fragment shader 中,查詢 uv 座標來獲取每個畫素的畫素值,從而渲染整個圖。

因此如果紋理圖是一張16:9 的,想要對映到一個長方形的面中,那麼紋理圖必要會被拉伸,就像我們上面的視訊一樣,上面的圖為了表現出電視機的厚度所以沒有那麼明顯,可以看一下的圖。(第一張比較暗是因為 Three.js 預設貼圖計算了光照,先忽略這一點)

我們先來捋一捋,假設我們的圖片的對映是按照 圖1-1,拉伸的情況下 (80,80,0) 對映的是 uv(1,1 ),但是其實我們期望的是點(80, 80 * 9/16, 0) 對映的是 uv(1,1),所以問題變成了畫素點位 (80, 80 * 9/16, 0) 的uv值 如何變成 (80, 80, 0) 的uv 值,更加簡單一些就是如何讓 80 * 9 / 16 變成 80,答案顯而易見,就是 讓 80 * 9 / 16 畫素點的 v 值 乘以 16 / 9,這樣就能找到了 uv(1,1) 的畫素值。然後我們就可以開始寫 shader 了。

// 在頂點著色器傳遞 uv
const vshader = `
varying vec2 vUv;

void main() {
  vUv = uv;

  gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
`

// 核心邏輯就是 vec2 uv = vUv * acept; 
const fshader = `
varying vec2 vUv;

uniform sampler2D u_tex;
uniform vec2 acept;

void main()
{
  vec2 uv = vUv * acept;
  vec3 color = vec3(0.3);
  if (uv.x>=0.0 && uv.y>=0.0 && uv.x<1.0 && uv.y<1.0) color = texture2D(u_tex, uv).rgb;
  gl_FragColor = vec4(color, 1.0);
}
`

然後我們看到我們畫面已經正常了,但是在整體螢幕的下方,所以還差一點點我們需要將它移動到螢幕的中央。

移動到中央的思路和上面差不多,我們只需要注重邊界點,假設邊界點 C 就是讓 80 * ( 0.5 + 9/16 * 0.5 ) 變成 80 ,很快我們也可能得出算是 C * 16/9 - 16/9 * 0.5 + 0.5 = 80

然後來修改 shader,頂點著色器不用改,我們只需要修改片段著色器。

const fshader = `
varying vec2 vUv;

uniform sampler2D u_tex;
uniform vec2 acept;

void main()
{
  vec2 uv = vec2(0.5) + vUv * acept - acept*0.5;
  vec3 color = vec3(0.0);
  if (uv.x>=0.0 && uv.y>=0.0 && uv.x<1.0 && uv.y<1.0) color = texture2D(u_tex, uv).rgb;
  gl_FragColor = vec4(color, 1.0);
}
`

好了,到現在為止,我們的影象顯示正常啦~

那麼 Three.js 中的 textureVideo 到底是如何實現視訊的播放的呢?

通過檢視原始碼(https://github.com/mrdoob/three.js/blob/6e897f9a42d615403dfa812b45663149f2d2db3e/src/textures/VideoTexture.js)原始碼非常的少,VideoTexture 繼承了 Texture ,最大的一點就是通過 requestVideoFrameCallback 這個方法,我們來看看它的定義,發現 mdn 沒有相關的範例,我們來到了 w3c 規範中尋找 https://wicg.github.io/video-rvfc/

這個屬性主要是獲取每一幀的圖形,可以通過以下的小 demo 來進行理解

<body>
  <video controls></video>
  <canvas width="640" height="360"></canvas>
  <span id="fps_text"/>
</body>

<script>
  function startDrawing() {
    var video = document.querySelector('video');
    var canvas = document.querySelector('canvas');
    var ctx = canvas.getContext('2d');

    var paint_count = 0;
    var start_time = 0.0;

    var updateCanvas = function(now) {
      if(start_time == 0.0)
        start_time = now;

      ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

      var elapsed = (now - start_time) / 1000.0;
      var fps = (++paint_count / elapsed).toFixed(3);
      document.querySelector('#fps_text').innerText = 'video fps: ' + fps;

      video.requestVideoFrameCallback(updateCanvas);
    }

    video.requestVideoFrameCallback(updateCanvas);

    video.src = "http://example.com/foo.webm"
    video.play()
  }
</script>

通過以上的理解,可以很容易抽象出整個過程,通過 requestVideoFrameCallback 獲取視訊每一幀的畫面,然後用 Texture 去渲染到物體上。

然後我們來加入 VR 程式碼, Three.js 預設給他們提供了建立 VR 的方法。

// step1 引入 VRButton
import { VRButton } from 'three/examples/jsm/webxr/VRButton.js';
// step2 將 VRButton 創造的dom新增進body 
document.body.appendChild( VRButton.createButton( renderer ) );
// step3 設定開啟 xr
renderer.xr.enabled = true;
// step4 修改更新函數
renderer.setAnimationLoop( function () {
	renderer.render( scene, camera );
} );

由於 iphone 太拉胯不支援 webXR ,特地借了檯安卓機(安卓機需要下載 Google Play、Chrome 、Google VR),新增以上步驟後,就會如下顯示:

點選 ENTER XR 按鈕後,即可進入 VR 場景。

然後我們我們可以再花20塊錢就可以買個谷歌眼鏡 cardboard。體驗地址如下:

https://fly-three-js.vercel.app/lesson03/code/index4.html

或者也可以像我一樣買一個 Oculus 然後躺著看大片

系列其他文章: