Three.js 實現3D全景偵探小遊戲?️

dragonir發表於2021-12-16

背景

你是嘿嘿嘿偵探社實習偵探?️,接到上級指派任務,到甄開心小鎮?調查市民甄不戳?寶石?失竊案,根據線人流浪漢老石?‍?提供的線索,小偷就躲在小鎮,快把他找出來,幫甄不戳尋回失竊的寶石吧!

本文使用 Three.js SphereGeometry 建立 3D 全景圖預覽功能,並在全景圖中新增二維 SpriteMaterialCanvas、三維 GLTF 等互動點,實現具備場景切換、點選互動的偵探小遊戲。

實現效果

左右滑動螢幕,找到 3D 全景場景中的 互動點 並點選,找出嫌疑人真正躲藏的位置。

已適配移動端,可以在手機上開啟訪問。

? 線上預覽:https://dragonir.github.io/3d...

程式碼實現

初始化場景

建立場景,新增攝像機、光源、渲染。

// 透視攝像機
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 1100);
camera.target = new THREE.Vector3(0, 0, 0);
scene = new THREE.Scene();
// 新增環境光
light = new THREE.HemisphereLight(0xffffff);
light.position.set(0, 40, 0);
scene.add(light);
// 新增平行光
light = new THREE.DirectionalLight(0xffffff);
light.position.set(0, 40, -10);
scene.add(light);
// 渲染
renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
container.appendChild(renderer.domElement);

使用球體實現全景功能

// 建立全景場景
geometry = new THREE.SphereGeometry(500, 60, 60);
// 按z軸翻轉
geometry.scale(1, 1, -1);
// 新增室外低畫質貼圖
outside_low = new THREE.MeshBasicMaterial({
  map: new THREE.TextureLoader().load('./assets/images/outside_low.jpg')
});
// 新增室內低畫質貼圖
inside_low = new THREE.MeshBasicMaterial({
  map: new THREE.TextureLoader().load('./assets/images/inside_low.jpg')
});
mesh = new THREE.Mesh(geometry, outside_low);
// 非同步載入高清紋理圖
new THREE.TextureLoader().load('./assets/images/outside.jpg', texture => {
  outside = new THREE.MeshBasicMaterial({
    map: texture
  });
  mesh.material = outside;
});
// 新增到場景中
scene.add(mesh);

? 全景貼圖如上圖所示,圖片來源於 Bing

? 球體 SphereGeometry

建構函式:

THREE.SphereGeometry(radius, segmentsWidth, segmentsHeight, phiStart, phiLength, thetaStart, thetaLength)
  • radius:半徑;
  • segmentsWidth:經度上的分段數;
  • segmentsHeight:緯度上的分段數;
  • phiStart:經度開始的弧度;
  • phiLength:經度跨過的弧度;
  • thetaStart:緯度開始的弧度;
  • thetaLength:緯度跨過的弧度。

? 基礎網格材質 MeshBasicMaterial

球體的材質使用的是 MeshBasicMaterial, 是一種簡單的材質,這種材質不受場景中光照的影響。使用這種材質的網格會被渲染成簡單的平面多邊形,而且也可以顯示幾何體的線框。

建構函式:

MeshBasicMaterial(parameters: Object)

parameters :(可選)用於定義材質外觀的物件,具有一個或多個屬性。

屬性:

  • .alphaMap[Texture]alpha 貼圖是一張灰度紋理,用於控制整個表面的不透明度。(黑色:完全透明;白色:完全不透明)。預設值為 null
  • .aoMap[Texture]:該紋理的紅色通道用作環境遮擋貼圖。預設值為 null
  • .aoMapIntensity[Float]:環境遮擋效果的強度。預設值為 1。零是不遮擋效果。
  • .color[Color]:材質的顏色,預設值為白色 0xffffff
  • .combine[Integer]:如何將表面顏色的結果與環境貼圖(如果有)結合起來。選項為THREE.Multiply(預設值),THREE.MixOperationTHREE.AddOperation。如果選擇多個,則使用 .reflectivity 在兩種顏色之間進行混合。
  • .envMap[Texture]:環境貼圖。預設值為 null
  • .lightMap[Texture]:光照貼圖。預設值為 null
  • .lightMapIntensity[Float]:烘焙光的強度。預設值為 1
  • .map[Texture]:紋理貼圖。預設為 null
  • .morphTargets[Boolean]:材質是否使用 morphTargets。預設值為 false
  • .reflectivity[Float]:環境貼圖對錶面的影響程度,預設值為 1,有效範圍介於 0(無反射)和 1(完全反射)之間。
  • .refractionRatio[Float]:折射率不應超過 1。預設值為 0.98
  • .specularMap[Texture]:材質使用的高光貼圖。預設值為 null
  • .wireframe[Boolean]:將幾何體渲染為線框。預設值為 false(即渲染為平面多邊形)。
  • .wireframeLinecap[String]:定義線兩端的外觀。可選值為 buttroundsquare。預設為 round
  • .wireframeLinejoin[String]:定義線連線節點的樣式。可選值為 round, bevelmiter。預設值為 round
  • .wireframeLinewidth[Float]:控制線框寬度。預設值為 1

? TextureLoader

TextureLoader 從給定的URL開始載入並將完全載入的 texture 傳遞給 onLoad。該方法還返回一個新的紋理物件,該紋理物件可以直接用於材質建立,載入材質的一個類,內部使用 ImageLoader 來載入檔案。

建構函式:

TextureLoader(manager: LoadingManager)
  • manager:載入器使用的 loadingManager,預設值為 THREE.DefaultLoadingManager

方法:

.load(url: String, onLoad: Function, onProgress: Function, onError: Function) : Texture
  • url:檔案的 URL 或者路徑,也可以為 Data URI
  • onLoad:載入完成時將呼叫。回撥引數為將要載入的 texture
  • onProgress:將在載入過程中進行呼叫。引數為 XMLHttpRequest 例項,例項包含 totalloaded 引數。
  • onError:在載入錯誤時被呼叫。

新增互動點

新建互動點陣列,包含每個互動點的名稱、縮放比例、空間座標。

var interactPoints = [
  { name: 'point_0_outside_house', scale: 2, x: 0, y: 1.5, z: 24 },
  { name: 'point_1_outside_car', scale: 3, x: 40, y: 1, z: -20 },
  { name: 'point_2_outside_people', scale: 3, x: -20, y: 1, z: -30 },
  { name: 'point_3_inside_eating_room', scale: 2, x: -30, y: 1, z: 20 },
  { name: 'point_4_inside_bed_room', scale: 3, x: 48, y: 0, z: -20 }
];

新增二維靜態圖片互動點

let pointMaterial = new THREE.SpriteMaterial({
  map: new THREE.TextureLoader().load('./assets/images/point.png')
});
interactPoints.map(item => {
  let point = new THREE.Sprite(pointMaterial);
  point.name = item.name;
  point.scale.set(item.scale * 1.2, item.scale * 1.2, item.scale * 1.2);
  point.position.set(item.x, item.y, item.z);
  scene.add(point);
});

? 精靈材質 SpriteMaterial

建構函式:

SpriteMaterial(parameters : Object)
  • parameters:可選,用於定義材質外觀的物件,具有一個或多個屬性。材質的任何屬性都可以從此處傳入(包括從 MaterialShaderMaterial 繼承的任何屬性)。
  • SpriteMaterials 不會被 Material.clippingPlanes 裁剪。

屬性:

.alphaMap[Texture]alpha 貼圖是一張灰度紋理,用於控制整個表面的不透明度。預設值為 null
.color[Color]:材質的顏色,預設值為白色 0xffffff.map 會和 color 相乘。
.map[Texture]:顏色貼圖。預設為 null
.rotation[Radians]sprite 的轉動,以弧度為單位。預設值為 0
.sizeAttenuation[Boolean]:精靈的大小是否會被相機深度衰減。(僅限透視攝像頭。)預設為 true

使用同樣的方法,載入嫌疑人二維圖片新增到場景中。

function loadMurderer() {
  let material = new THREE.SpriteMaterial({
    map: new THREE.TextureLoader().load('./assets/models/murderer.png')
  });
  murderer = new THREE.Sprite(material);
  murderer.name = 'murderer';
  murderer.scale.set(12, 12, 12);
  murderer.position.set(43, -3, -20);
  scene.add(murderer);
}

新增三維動態模型錨點

通過載入地標錨點形狀的 gltf 模型來實現三維動態錨點,載入 gltf 需要單獨引入 GLTFLoader.js,地標模型使用 Blender 構建。

var loader = new THREE.GLTFLoader();
loader.load('./assets/models/anchor.gltf', object => {
  object.scene.traverse(child => {
    if (child.isMesh) {
      // 修改材質樣式
      child.material.metalness = .4;
      child.name.includes('黃') && (child.material.color = new THREE.Color(0xfffc00))
    }
  });
  object.scene.rotation.y = Math.PI / 2;
  interactPoints.map(item => {
    let anchor = object.scene.clone();
    anchor.position.set(item.x, item.y + 3, item.z);
    anchor.name = item.name;
    anchor.scale.set(item.scale * 3, item.scale * 3, item.scale * 3);
    scene.add(anchor);
  })
});

需要在 requestAnimationFrame 中通過修改模型的 rotation 來實現自傳動畫效果。

function animate() {
  requestAnimationFrame(animate);
  anchorMeshes.map(item => {
    item.rotation.y += 0.02;
  });
}

新增二維文字提示

可以使用 Canvas 建立文字提示新增到場景中。

function makeTextSprite(message, parameters) {
  if (parameters === undefined) parameters = {};
  var fontface = parameters.hasOwnProperty("fontface") ? parameters["fontface"] : "Arial";
  var fontsize = parameters.hasOwnProperty("fontsize") ? parameters["fontsize"] : 32;
  var borderThickness = parameters.hasOwnProperty("borderThickness") ? parameters["borderThickness"] : 4;
  var borderColor = parameters.hasOwnProperty("borderColor") ? parameters["borderColor"] : { r: 0, g: 0, b: 0, a: 1.0 };
  var canvas = document.createElement('canvas');
  var context = canvas.getContext('2d');
  context.font = fontsize + "px " + fontface;
  var metrics = context.measureText(message);
  var textWidth = metrics.width;
  context.strokeStyle = "rgba(" + borderColor.r + "," + borderColor.g + "," + borderColor.b + "," + borderColor.a + ")";
  context.lineWidth = borderThickness;
  context.fillStyle = "#fffc00";
  context.fillText(message, borderThickness, fontsize + borderThickness);
  context.font = 48 + "px " + fontface;
  var texture = new THREE.Texture(canvas);
  texture.needsUpdate = true;
  var spriteMaterial = new THREE.SpriteMaterial({ map: texture });
  var sprite = new THREE.Sprite(spriteMaterial);
  return sprite;
}

使用方法:

outsideTextTip = makeTextSprite('進入室內查詢');
outsideTextTip.scale.set(2.2, 2.2, 2)
outsideTextTip.position.set(-0.35, -1, 10);
scene.add(outsideTextTip);
  • ? Canvas 畫布可以作為 Three.js 紋理貼圖 CanvasTextureCanvas 畫布可以通過 2D API 繪製各種各樣的幾何形狀,可以通過 Canvas 繪製一個輪廓後然後作為 Three.js 網格模型、精靈模型等模型物件的紋理貼圖。
  • ? measureText()方法返回一個物件,該物件包含以畫素計的指定字型寬度。如果您需要在文字向畫布輸出之前,就瞭解文字的寬度,那麼請使用該方法。measureText 語法:context.measureText(text).width

新增三維文字提示

由於時間有限,三維文字 本示例中並未用到,但是在頁面中使用 3D 文字會實現更好的視覺效果,想了解具體實現細節,可以閱讀我的另一篇文章,後續的滑鼠捕獲等內容也在該文中有詳細講解。

? 傳送門:使用three.js實現炫酷的酸性風格3D頁面

滑鼠捕獲

使用 Raycaster 獲取點選選中網格物件,並新增點選互動。

function onDocumentMouseDown(event) {
  raycaster.setFromCamera(mouse, camera);
  var intersects = raycaster.intersectObjects(interactMeshes);
  if (intersects.length > 0) {
    let name = intersects[0].object.name;
    if (name === 'point_0_outside_house') {
      camera_time = 1;
    } else if (name === 'point_4_inside_bed_room') {
      Toast('小偷就在這裡', 2000);
      loadMurderer();
    } else {
      Toast(`小偷不在${name.includes('car') ? '車裡' : name.includes('people') ? '人群' : name.includes('eating') ? '餐廳' : '這裡'}`, 2000);
    }
  }
  onPointerDownPointerX = event.clientX;
  onPointerDownPointerY = event.clientY;
  onPointerDownLon = lon;
  onPointerDownLat = lat;
}

場景切換

function update() {
  lat = Math.max(-85, Math.min(85, lat));
  phi = THREE.Math.degToRad(90 - lat);
  theta = THREE.Math.degToRad(lon);
  camera.target.x = 500 * Math.sin(phi) * Math.cos(theta);
  camera.target.y = 500 * Math.cos(phi);
  camera.target.z = 500 * Math.sin(phi) * Math.sin(theta);
  camera.lookAt(camera.target);
  if (camera_time > 0 && camera_time < 50) {
    camera.target.x = 0;
    camera.target.y = 1;
    camera.target.z = 24;
    camera.lookAt(camera.target);
    camera.fov -= 1;
    camera.updateProjectionMatrix();
    camera_time++;
    outsideTextTip.visible = false;
  } else if (camera_time === 50) {
    lat = -2;
    lon = 182;
    camera_time = 0;
    camera.fov = 75;
    camera.updateProjectionMatrix();
    mesh.material = inside_low;
    // 載入新的全景圖場景
    new THREE.TextureLoader().load('./assets/images/inside.jpg', function (texture) {
      inside = new THREE.MeshBasicMaterial({
        map: texture
      });
      mesh.material = inside;
    });
    loadMarker('inside');
  }
  renderer.render(scene, camera);
}
  • ? 透視相機的屬性建立完成後我們可以根據個人需求隨意修改,但是相機的屬性修改後,需要呼叫 updateProjectionMatrix() 方法來更新。
  • ? THREE.Math.degToRad:將度轉化弧度。

到這裡,3D 全景功能全部實現。

? 完整程式碼:https://github.com/dragonir/3...

總結

本案例主要涉及到的知識點包括:

  • 球體 SphereGeometry
  • 基礎網格材質 MeshBasicMaterial
  • 精靈材質 SpriteMaterial
  • 材質載入 TextureLoader
  • 文字紋理 Canvas
  • 滑鼠捕獲 Raycaster

參考資料

相關文章