阅读 1200

在霸王龙的威压下,小明勇敢射月

我正在参加中秋创意投稿大赛,详情请看:中秋创意投稿大赛

在线预览

由于霸王龙模型比较大(20M+),需要等待一段时间。

1.引子

古有后羿射日,后裔成为英雄后,上天奖励不死药,然后被嫦娥所偷。固有“嫦娥应悔偷灵药,碧海青天夜夜心”的诗句。

今日,小明追随偶像后裔的脚步,准备了三级火力机枪,在霸王龙的威压下,勇敢射月,聊解嫦娥寂寞。

2.画个草图

打开画图软件构思一下,大概就长这个样子

草图.png

3.元素分析

  1. 两个人物
  2. 两个球体
  3. 一只恐龙
  4. 子弹飞出去的弹道

渲染引擎使用three.js,为了写起来更方便,使用 simple-scene-react 库(该库在# 基于three.js的太、地、月三体运动有介绍)。

球体使用贴图即可,人物为了简单使用精灵图。

小明

me.png

嫦娥

嫦娥.png

对于霸王龙,我在模型库中找到了.glb文件,

image.png

攻击弹道使用 fly-linefly-line 是封装笔者的一个简单飞线效果)。

4.fly-line介绍

simple-scene-react一样,飞线效果用的比较多,所以就打包发了一个npm包

示例 3D场景应用 image.png 2D场景应用 image.png 地图中的应用 image.png 小明射击练习 image.png 代码见/example

5.构造类

5.1.天体类

class Star {
  name: string;
  image: any;
  highImage: any;
  raduis: number;
  position: number[];
  cita: number;
  mesh: any 
  constructor(
    name: string,
    image: any,
    highImage: any,
    raduis: number,
    position: number[],
    cita: number,
  ) {
    this.name = name;
    this.image = image;
    this.highImage = highImage;
    this.raduis = raduis;
    this.position = position;
    this.cita = cita;
  }
}
复制代码
属性说明
name天体名称
image贴图图片
highImage置换贴图图片
raduis天体半径
position天体位置
cita公转角度

5.2.Human类

class Human {
  name: string;
  image: any;
  position: number[];
  mesh: any 
  constructor(
    name: string,
    image: any,
    position: number[],
  ) {
    this.name = name;
    this.image = image;
    this.position = position;
  }
}
复制代码
属性说明
name名字
image精灵图
position位置坐标

霸王龙比较独立,就懒得写类了。

6.渲染

6.1.天体渲染

定义:

const stars = [
  new Star(
    'Earth',
    require('./texture/earth.jpg'),
    require('./texture/earth-high.jpg'),
    300,
    [0, -300, 600],
    0
  ),
  new Star(
    'Moon',
    require('./texture/moon.jpg'),
    require('./texture/moon-high.jpg'),
    150,
    [600, 400, -1000],
    0
  ),
];
复制代码

因为想要有更好的显示,这里采用了置换贴图。我在文章# Echarts 3D Earth "冰山一角"揭秘 | 创作者训练营第二期比较了置换贴图、凹凸贴图、法相贴图的优劣。 image.png

const addStars = async (scene: THREE.Scene) => {
  for (let i = 0; i < stars.length; i++) {
    let star = stars[i];
    let sphereGeo = new THREE.SphereGeometry(star.raduis, 50, 50); //创建一个球体几何对象
    let img = await ImageLoader.load(star.image);
    let texture = new THREE.Texture(img);
    texture.needsUpdate = true;
    let imgH = await ImageLoader.load(star.highImage);
    let highTexture = new THREE.Texture(imgH);
    let material = new THREE.MeshStandardMaterial({
      map: texture,
      displacementMap: highTexture, // 置换贴图纹理
      displacementScale: i === 0 ? 40 : 5,
      displacementBias: 10,
    });
    (material.displacementMap as any).needsUpdate = true;
    let sphereMesh = new THREE.Mesh(sphereGeo, material);
    // 设置位置
    sphereMesh.position.set(
      star.position[0],
      star.position[1],
      star.position[2]
    ); 
    sphereMesh.name = star.name;
    star.mesh = sphereMesh;
    if (i === 0) {
      sphereMesh.rotation.z = Math.PI / 6;
      sphereMesh.rotation.y = (3 * Math.PI) / 2;
    }
    scene.add(sphereMesh);
  }
};
复制代码

这样可以是球体表面有凹凸性。

image.png image.png

6.2.人物渲染

const addHumans = async (scene: THREE.Scene) => {
  for (let i = 0; i < humans.length; i++) {
    let human = humans[i];
    let img = await ImageLoader.load(human.image);
    let texture = new THREE.Texture(img);
    texture.needsUpdate = true;
    let spriteMaterial = new THREE.SpriteMaterial({
      rotation: i === 0 ? Math.PI / 6 : 2 * Math.PI, 
      map: texture, 
    });
    let mesh = new THREE.Sprite(spriteMaterial);
    mesh.position.set(human.position[0], human.position[1], human.position[2]); //几何体中心位置
    mesh.name = human.name;
    human.mesh = mesh;
    mesh.scale.set(100, 100, 1);
    scene.add(mesh);
  }
};
复制代码

精灵图其实就是始终面向摄像头的平面。可见Three.js电子书进行参考。效果如下:

image.png

不好意思,这个角度只让小明露了个头,没有办法,他可是要躲避霸王龙的攻击的。

image.png

6.3.霸王龙渲染

首选需要一个GLTFLoader

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const GLBLoader = new GLTFLoader();
复制代码

然后进行load

const addRampaging = async (scene: THREE.Scene) => {
  GLBLoader.load(rampaging, gltf => {
    let modal = gltf.scene;
    modal.scale.set(50, 50, 50);
    mixer = new THREE.AnimationMixer(modal); // mixer 为全局变量
    animations = gltf.animations; // animations 为全局变量
    mixer.clipAction(animations[1]).play(); // 默认播放第二个动画片段
    modal.rotation.y = Math.PI / 2;
    modal.position.set(-80, 0, 600);
    scene.add(modal); 
  });
};
复制代码

需要注意glb模型有再带的animations,我将其存到了全局变量 animations中,且默认使用第二个animation片段,见注释。其余的动作我将其入口放到了左上角第二行。

{['run', 'bit', 'roar', 'tail', 'idke'].map((item, index) => (
  <button
    key={item}
    onClick={() => {
      if (mixer) {
        mixer.stopAllAction();
        mixer.clipAction(animations[index]).play();
      }
    }}
  >
    {item}
  </button>
))}
复制代码

image.png 可以访问 在线预览 进行尝试。

动画的执行使用mixer = new THREE.AnimationMixer(modal)对象,在使用clipAction().play()之后,在animate(动画循环)函数使用mixer.update(delta)即可。

6.4.弹道攻击渲染

如果只用直线渲染,小明会觉得很单调,按小明的想法,他想给嫦娥一个如烟花般的惊喜。 image.png

这种曲线如果用贝塞尔曲线来实现,那么其重点在于控制点的计算,即THREE.QuadraticBezierCurve3的第二个参数。

于是小明拿出了纸和笔。

image.png

  1. 已知起点A与终点B
  2. 可以计算出AB的中点C(xc,yc,zc)(x_c,y_c,z_c)
  3. C点做垂直于向量AB的平面;
  4. 这个时候可以得到平面方程A(xxc)+B(yyc)+C(zzc)=0A(x - x_c) + B(y-y_c) + C(z-z_c) = 0
  5. 问题等价于已知平面P,和平面P上的点C,做一个半径为r的圆,求圆的参数方程。

B371BB3C61A04702018DAFA7ABC311D0.jpg 小明算了几张纸发现这并不好算,于是改变思路。

1.构造一个XY平面的圆; 2.将其平移到C; 3.然后做一个绕轴旋转就可以得到答案。

也就是先作用一个平移变换矩阵,然后再作用一个旋转矩阵即可。

但是非X,Y,Z轴旋转矩阵小明不知道长啥样,小明陷入了沉思。

后来他发现THREE.Mesh有绕轴旋转的APIsetRotationFromAxisAngle

于是他写下了如下code来调试他的机关枪。

// 生成一个XY平面圆,圆心[0,0],半径200
let arc = new THREE.ArcCurve(0, 0, 200, 0, 2 * Math.PI, true);
// 拿到圆上50个点
let points = arc.getPoints(50);
// 根据点拿到几何体
let geometry = new THREE.BufferGeometry().setFromPoints(points);
// 让顶点数据更新
geometry.attributes.position.needsUpdate = true; 
// 材质
let material = new THREE.LineBasicMaterial({ color: 0x00ff00 });
// Mesh对象
let circle = new THREE.Line(geometry, material);
// 平移到点C
circle.translateX(C.x);
circle.translateY(C.y);
circle.translateZ(C.z);
// 绕轴旋转
// AB方向向量p
let p = A.clone().sub(B); // 不加clone会改变A向量
// XY平面圆的向量为 [0,0,1]
let _p = new THREE.Vector3(0, 0, 1);
// 相当于从法向量 p 旋转到了 _p
// 向量夹角
let cita = p.angleTo(_p)
// 旋转轴应该垂直于 p _p 构成的平面
let axis = p.clone().cross(_p).normalize(); // 再标准化一下
// 进行旋转
circle.setRotationFromAxisAngle(axis, cita)
复制代码

circle添加到scene之后,成功的在指定位置上渲染了。 image.png

离目标只差一步,从Mesh中拿到顶点数据就可以了。

尝试了各种办法,如geometry.attributes.position.needsUpdate = true,解析circle.geometry.getAttribute('position').array得到points,然后都失败的发现其不是最新值。

于是小明转了一个弯,为啥要操作Mesh呢,直接操作Vector不就没有中间商赚差价了吗?Vector上也是有绕轴旋转方法applyAxisAngle的。

/**
 * 
 * @param axis 指定坐标轴
 * @param angle 旋转的角度
 * @param offset 平移的向量
 * @param r 半径
 * @param num 点的个数
 * @returns points
 */
const generatePoints = (
  axis: THREE.Vector3,
  angle: number,
  offset: THREE.Vector3,
  r: number,
  num: number
) => {
  let arr: THREE.Vector3[] = [];
  for (let i = 0; i <= num; i++) {
    let cita = (2 * Math.PI * i) / num;
    let p = new THREE.Vector3(r * Math.cos(cita), r * Math.sin(cita), 0);
    p.applyAxisAngle(axis, angle);
    p.add(offset);
    arr.push(p);
  }
  return arr;
};
复制代码

小明成功了,他给他的机关枪安装了三级火力,一级比一级强,最后猛烈的朝月球射去。

7.动画

animate比较简单,直接贴一发代码,line_1,line_2,line_3代表了三级火力。

  const animate = (target: any, clock: THREE.Clock) => {
    let delta = clock.getDelta();
    if (mixer) {
      mixer.update(delta);
    }
    lines_1.forEach(flyLine => {
      flyLine.animate();
    });
    lines_2.forEach(flyLine => {
      flyLine.animate();
    });
    lines_3.forEach(flyLine => {
      flyLine.animate();
    });
    // 自转
    stars.forEach(star => {
      if (star.mesh) {
        star.mesh.rotation.y += delta;
      }
    });
  };
复制代码

详细代码

8.结语

转换视角,盖以嫦娥妹妹的角度来看,小明正在与霸王龙进行精彩的搏斗呢?

image.png

在线链接

欢迎来玩,欢迎使用fly-line

文章分类
前端