視窗縮放處理好了,今天要做找彩蛋的最核心技術:怎麼知道玩家點的是不是藏起來的彩蛋。
今天先研究 Raycasting 並只先做出點一下畫面,Console 印出點到的資訊。
官方文件的說明:
Raycasting is used for mouse picking (working out what objects in the 3d space the mouse is over) amongst other things.
意思:被用於滑鼠選取(也就是算出滑鼠目前所在位置,對應到 3D 空間裡的哪個物體),以及其他用途。
比喻: 想像拿一支雷射筆,站在螢幕前面,對著畫面上某一點射出去。雷射會穿過螢幕,射進你佈置好的 3D 場景裡,打中它第一個碰到的物體。Raycasting 做的就是這件事,只是用數學算出這條線會先打中誰,不是真的有一道光。
滑鼠在螢幕上的座標,瀏覽器給的是像素,例如 (960, 540),左上角是 (0, 0)。但 Three.js 的 Raycaster 要的不是這個,依官方文件對 setFromCamera 的說明:
2D coordinates of the mouse, in normalized device coordinates (NDC)---X and Y components should be between
-1and1.
這叫 NDC(標準化裝置座標),範圍是 -1 到 1,而且原點在畫面正中央,往右上是正的,往左下是負的。
換算公式:
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
換算講解
NDC 的 X、Y 範圍是 -1 到 1,原點 0 在正中央。
而瀏覽器像素座標是 0 ~ window.innerWidth, 0 ~ window.innerHeight
X 軸:
我們先做正規化 (x / window.innerWidth) → 讓範圍變成 0 ~ 1
接著 * 2 - 1 達到 -1 ~ 1,也就是 Three.js 的 Vector2 範圍
Y 軸不能直接套用同一個公式,因為方向相反:
像素座標的 Y 愈往下數字愈大(最上面是 0),但 NDC 的 Y 愈往上數值愈大(上方是正值)。兩者方向相反,所以做完正規化 (y / window.innerHeight) 後,要先在前面乘上 -1 把方向反過來,寫法就變成 -(y / window.innerHeight) * 2 + 1,而不是 (y / window.innerHeight) * 2 - 1。
用圖示理解這個轉換:
瀏覽器的像素座標(左上角是原點) Raycaster 要的 NDC(正中央是原點)
(0,0) (1920,0) (-1, 1) (1, 1)
┌─────────────────────────┐ ┌─────────────────────────┐
│ │ │ │
│ ●(960,540) │ ──→ │ ●(0, 0) │
│ │ │ │
└─────────────────────────┘ └─────────────────────────┘
(0,1080) (1920,1080) (-1,-1) (1,-1)
為什麼 Y 的公式前面要加負號: 瀏覽器的像素座標,Y 愈往下數字愈大;NDC 剛好相反,Y 愈往上數值愈大。所以公式裡故意加一個負號,把方向反過來。
import * as THREE from 'three';
// ...(Day16 原本的 scene、camera、renderer、燈光、controls、GLTFLoader、resize 都不動)
// 以下是今天新加的部分
const raycaster = new THREE.Raycaster();
const coords = new THREE.Vector2();
window.addEventListener('click', (event) => {
// 1. 把滑鼠的像素座標,換算成 NDC
coords .x = (event.clientX / window.innerWidth) * 2 - 1;
coords .y = -(event.clientY / window.innerHeight) * 2 + 1;
// 2. 從相機、往滑鼠指的方向,射出這條線
raycaster.setFromCamera(coords , camera);
// 3. 問這條線,打中了 scene 裡的哪些東西(true 代表連子物件也要檢查)
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
console.log('點到了:', intersects[0].object.name, intersects[0].object);
} else {
console.log('沒點到任何東西');
}
});
逐行對照官方文件:
new THREE.Raycaster():建立一個 Raycaster。依官方文件,建構子可以帶入起點、方向等參數,但這裡用 setFromCamera 會自動幫忙設定,所以不用自己填。
raycaster.setFromCamera(coords, camera):用相機的位置、加上滑鼠指的方向,算出射線該怎麼射。不過 setFromCamera 的 coords 是一個 Vector2,所以在使用前需要產生 Vector2 物件,也就是例子中的 coords. 計算方式前面在介紹 NDC 時已說明,這裡就不再贅述
raycaster.intersectObjects(scene.children, true):依官方文件,這個方法會回傳一個陣列,列出射線打中的所有物體,依距離近到遠排序。第二個參數 recursive 設 true,代表連 gltf.scene 底下包著的子物件(Mesh)也要一起檢查,不然只會檢查到最外層那個 Group,檢查不到裡面真正的模型。
intersects[0]:排序後的第一個,就是離相機最近、第一個被打中的物體,這正是「點到了什麼」的答案。
click 不是滑鼠移動官方範例常常用 mousemove(滑鼠移動時就一直檢查),適合「滑鼠滑過去就要變色」這種效果。找威利遊戲是「點擊才算數」,所以這裡選 click 事件,玩家按一下才去判斷,不用每次滑鼠移動都算一次(場景物件越多,每次都算會越耗效能,click 比 mousemove 省資源)。
打開瀏覽器,隨便點公園模型上的幾個地方,看 Console 印出什麼。
可以看到 Mesh 物件、uuid,以及很奇怪的名字,把資訊展開如下
模型裡的物件名稱是 Blender 匯出時自動產生的,不一定有意義。這沒關係,今天只求「能抓到有沒有點中東西」,下一步才是「怎麼認出點到的是彩蛋」。
已經能抓到「點到了什麼」,接下來要讓程式認出「點到的剛好是彩蛋」,點中時跳出通知訊息,預計明天就能把整個流程都打通起來,之後再花時間調整輸出模型的完整度。