three.js簡單實現類似七聖召喚的擲骰子

1基本工作

筆者利用業餘時間自學瞭three.js。為瞭更好的瞭解WebGL以及更熟練的使用three,想模仿原神中的小遊戲“七聖召喚”中的投擲骰子效果,作為首個練習項目~~ 這是堅持寫技術博客的第二周,也是首篇在掘金寫的文章,人生路遠,仍需遠行。

  • 為瞭方便直接用vite創建瞭vue項目
  • npm下載three.js和cannon-es,最重要的兩個庫~

1.1 創建場景

直接貼代碼~

/**
* 創建場景對象Scene
 */
const scene = new THREE.Scene();
/**
 * 創建網格模型
 */
const geometry = new THREE.BoxGeometry(300, 300, 5); //創建一個立方體幾何對象Geometry
const material = new THREE.MeshPhongMaterial({
  color: 0x845EC2,
  antialias: true,
  alpha: true
}); //材質對象Material
const desk = new THREE.Mesh(geometry, material); //網格模型對象Mesh
desk.receiveShadow = true;
desk.rotateX(Math.PI * 0.5)
scene.add(desk); //網格模型添加到場景中
//聚光燈
const light = new THREE.SpotLight(0xffffff);
light.position.set(20, 220, 100); //光源位置
light.castShadow = true;
light.shadow.mapSize.width = 2048;
light.shadow.mapSize.height = 2048;
scene.add(light); //點光源添加到場景中
//環境光
const ambient = new THREE.AmbientLight(0x666666);
scene.add(ambient);
// 相機設置
const width = window.innerWidth; //窗口寬度
const height = window.innerHeight; //窗口高度
const k = width / height; //窗口寬高比
const s = 70; //三維場景顯示范圍控制系數,系數越大,顯示的范圍越大
//創建相機對象
const camera = new THREE.OrthographicCamera(-s * k, s * k, s, -s, 1, 1000);
camera.position.set(0, 200, 450); //設置相機位置
camera.lookAt(scene.position); //設置相機方向(指向的場景對象)
/**
 * 創建渲染器對象
 */
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setSize(width, height);//設置渲染區域尺寸
renderer.setClearColor(0xb9d3ff, 1); //設置背景顏色
document.getElementById("app").appendChild(renderer.domElement) //插入canvas對象
//執行渲染操作   指定場景、相機作為參數
function render() {
  renderer.render(scene, camera);
}
render();

1.2 創建物理世界

const world = new CANNON.World();
world.gravity.set(0, -9.82, 0);
world.allowSleep = true;
const floorBody = new CANNON.Body({
  mass: 0,
  shape: new CANNON.Plane(),
  position: new CANNON.Vec3(0, 3, 0),
})
// 由於平面初始化是是豎立著的,所以需要將其旋轉至跟現實中的地板一樣 橫著
// 在cannon.js中,我們隻能使用四元數(Quaternion)來旋轉,可以通過setFromAxisAngle(…)方法,第一個參數是旋轉軸,第二個參數是角度
floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(-1, 0, 0), Math.PI * 0.5)
world.addBody(floorBody)
const fixedTimeStep = 1.0 / 60.0; // seconds
const maxSubSteps = 3;
// loop
let lastTime;
(function animate(time) {
  requestAnimationFrame(animate);
  if (lastTime !== undefined) {
    var dt = (time - lastTime) / 500;
    world.step(fixedTimeStep, dt, maxSubSteps);
  }
  dice_manager.update_all();
  render();
  lastTime = time;
})();

至此基本物理世界場景就創建完成。接下來我們需要一個生成骰子的函數。

2 骰子

2.1 骰子模型

很簡單,直接使用new THREE.OctahedronGeometry(),這個構造函數會返回一個八面立方體。
並且我們需要一個八面都是不同顏色的骰子。

  const rgb_arr = [
    [161, 178, 74],
    [255, 150, 75],
    [176, 103, 208],
    [219, 168, 79],
    [20, 204, 238],
    [109, 210, 192],
    [166, 228, 241],
    [255, 255, 255],
  ];
  const color_arr = [];
  rgb_arr.map((val_arr) => {
    for (let i = 0; i < 3; i++) {
      val_arr.map((val) => {
        color_arr.push(val / 255);
      });
    }
  });
  const color = new Float32Array(color_arr);
  geometry.attributes.color = new THREE.BufferAttribute(color, 3);
  const material = new THREE.MeshLambertMaterial({
    vertexColors: true, 
    side: THREE.DoubleSide,
  });
  const polyhedron_mesh = new THREE.Mesh(geometry, material);
  • THREE.BufferAttribute接收的rbg的值為0~1,所以還需要將原始的rbg值除以255。
  • vertexColors設為true,表示以頂點數據為準。

好像相差有點大。。不過我們還是得到瞭一個八面的骰子(沒有高清的元素圖標貼圖,隻能勉強看看~)

2.2 骰子物理

根據上面弄好的骰子模型生成一個骰子的物理模型。

const create_dice_shape = (mesh) => {
  let geometry = new THREE.BufferGeometry();
  geometry.setAttribute("position", mesh.geometry.getAttribute("position"));
  geometry = mergeVertices(geometry);
  const position = geometry.attributes.position.array;
  const index = geometry.index.array;
  const vertices = [];
  // 轉換成cannon需要的頂點和面
  for (let i = 0, len = position.length; i < len; i += 3) {
    vertices.push(
      new CANNON.Vec3(position[i], position[i + 1], position[i + 2])
    );
  }
  const faces = [];
  for (let i = 0, len = index.length; i < len; i += 3) {
    faces.push([index[i], index[i + 1], index[i + 2]]);
  }
  // 生成cannon凸多面體
  return new CANNON.ConvexPolyhedron({ vertices, faces });
};

有瞭ConvexPolyhedron我們就可以創建一個body物理模型瞭

const body = new CANNON.Body({
    mass: 10,
    shape,
  });

將渲染模型和物理模型綁定起來:

update: () => {
      mesh.position.copy(body.position);
      mesh.quaternion.copy(body.quaternion);
    },

設置body參數的函數,來讓我們可以投擲骰子:

init_body: (position) => {
      body.position = position;
      // 設置加速度和向下的速度
      body.angularVelocity.set(Math.random(), Math.random(), Math.random());
      body.velocity.set(0, -80, 0);
      body.sleepState = 0; //將sleepState設為0 不然重置後不會運動
    },

fine~相當不錯

2.3 判斷骰子的頂面

關於如何判斷骰子的頂面,翻遍瞭谷歌和百度,始終沒有好結果。

發一下牢騷,在互聯網上搜索的幾乎全是不相關的內容。要麼就是一眾的采集站,要麼一樣的帖子大夥們反復轉載反復寫,甚至還有拿開源項目賣錢的。讓我體會瞭什麼叫“知識庫污染”。

既然沒有現成的方案,那就隻能自己想咯。我們知道three有個Group類,他用於將多個模型組合成一個組一起運動。由此想到兩個相對可行的方案:(有沒有大佬分享更好的辦法啊~

方案一

骰子每個面弄成多個mesh組合成一個THREE.Group(),在骰子停止時獲取所有骰子的位置,THREE.Raycaster()在每個骰子的上面生成射線並朝向骰子,此時相交的第一個模型就是骰子的頂面。
缺點: 太復雜,物理模型不好弄,pass掉~

方案二

骰子還是那個骰子,但是在每個面上創建一個不可見的模型,並用THREE.Group()綁定到一塊兒,隨著骰子一起運動,停下時,獲取每個骰子y軸最大的定位點,也就是最高的那個,便是骰子的頂面。
缺點: 沒想到,但應該比方案一好。

具體實現

首先創建一個函數,它用於在骰子相應的地方創建一個不可見的模型。

const create_basic_mesh = (position, name) => {
  const geometry = new THREE.BufferGeometry();
  const vertices = new Float32Array([0, 0, 0]);
  geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
  const mesh = new THREE.Mesh(geometry);
  [mesh.position.y, mesh.position.x, mesh.position.z] = position;
  mesh.name = name; //標記面的點數
  return mesh;
};

將其包裝成一個組,其中頂點位置後的參數(grass等等)用於標記點數,代表著遊戲中的七大元素以及萬能元素。

// 初始化點數位置
const init_points = (mesh) => {
  const group = new THREE.Group();
  group.add(mesh);
  group.name = "dice";
  group.add(create_basic_mesh([5, 5, 5], "grass"));
  group.add(create_basic_mesh([5, -5, 5], "universal"));
  group.add(create_basic_mesh([5, -5, -5], "water"));
  group.add(create_basic_mesh([5, 5, -5], "rock"));
  group.add(create_basic_mesh([-5, 5, 5], "fire"));
  group.add(create_basic_mesh([-5, -5, 5], "ice"));
  group.add(create_basic_mesh([-5, -5, -5], "wind"));
  group.add(create_basic_mesh([-5, 5, -5], "thunder"));
  return group;
};

差不多就是這樣,為瞭方便調試,我暫時把它渲染成瞭可見的。

判斷頂面,隻需要獲取它們中最高的那一個即可

get_top: () => {
      let top_face,
        max = 0;
      mesh.children.map((val, index) => {
        if (index == 0) return;
        val.updateMatrixWorld(); //更新模型的世界矩陣
        let worldPosition = new THREE.Vector3();
        val.getWorldPosition(worldPosition); //獲取模型在世界中的位置
        if (max < worldPosition.y) {
          max = worldPosition.y;
          top_face = val.name;
        }
      });
      return top_face;
    },

2.4 鎖定骰子

在七聖召喚中每一次重隨都能鎖定骰子,被鎖定的骰子會移動到旁邊並且不會參與重隨。

//鼠標選中模型
const choose = (event) => {
  let mouseX = event.clientX;//鼠標單擊位置橫坐標
  let mouseY = event.clientY;//鼠標單擊位置縱坐標 
  //屏幕坐標轉標準設備坐標
  const x = (mouseX / window.innerWidth) * 2 - 1;
  const y = - (mouseY / window.innerHeight) * 2 + 1;
  let standardVector = new THREE.Vector3(x, y);//標準設備坐標
  //標準設備坐標轉世界坐標
  let worldVector = standardVector.unproject(camera);
  //射線投射方向單位向量(worldVector坐標減相機位置坐標)
  let ray = worldVector.sub(camera.position).normalize();
  //創建射線投射器對象 
  let raycaster = new THREE.Raycaster(camera.position, ray);
  raycaster.camera = camera//設置一下相機
  let intersects = raycaster.intersectObjects(dice_meshs);
  //長度大於0說明選中瞭骰子
  if (intersects.length > 0) {
    let dice_name = intersects[0]?.object.parent.name;
    locked_dice.push(dice_name);
    dice_manager.move_dice(dice_name, new CANNON.Vec3(135, 10, (-100 + locked_dice.length * 20))) //移動骰子
  }
}
addEventListener('click', choose); // 監聽窗口鼠標單擊事件

move_dice函數

// 移動骰子到相應位置
move_dice: (name, position) => {
      for (let i = 0; i < dice_arr.length; i++) {
        if (name == dice_arr[i].mesh.name) {
          dice_arr[i].body.position = position;
          break;
        }
      }
    },

重隨時需要判斷被鎖定的骰子。

init_dice: (exclude_dices) => {
      for (let i = 0; i < dice_arr.length ; i++) {
        if(!exclude_dices.includes(dice_arr[i].mesh.name)){
          dice_arr[i].init_body(new CANNON.Vec3(-(i % 4) * 21, 100, i * 6));
        }
      }
    },

按照慣例測試一下。

基本上就差不多完工瞭,但是還有很多細節可以慢慢打磨,更多關於three.js七聖召喚擲骰子的資料請關註WalkonNet其它相關文章!

推薦閱讀: