1.Three.js系列: 寫一個第一/三人稱視角小遊戲
2.Three.js系列: 造個海洋球池來學習物理引擎
本文 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 然後躺著看大片