ThreeJS 轻松实现主视觉太阳系漫游

ThreeJS 轻松实现主视觉太阳系漫游

其实随着浏览器性能越来越强大,在Web端实现3D的场景或者游戏也会越来越流畅。今天跟大家分享一个小项目,通过ThreeJS实现简单的太阳系模型、恒星背景等。

预览: Solar System

源码在 GitHub - SoAanyip/SolarSystem: Simple Three.js solar system. , 可以直接对照着源码查看

需要准备什么

  1. 这篇分享默认您至少有ThreeJS的基础知识(camera, scene等),这里有两个链接可以参考:张雯莉的Three.js入门指南, 英文文档 ;
  2. 由于使用es2015部分属性直接编写,所以需要babel转译(当然自己做着玩的话,就不用了);
  3. Three默认不允许加载本机上的纹理文件(图片),用webpack开一个本地服务器就好;
  4. 编写中用到的libs都可以从官网上下载。

编码部分

初始化

首先写一个简单的页面,准备一个canvas容器:


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <title>Solar System</title>
    <style>
        body{
            margin:0;
            padding: 0;
            overflow: hidden;
        }
    </style>
</head>
<body>
    <canvas id="main"></canvas>

    <script src="../resource/js/three/three.js"></script>
    <script src="http://localhost:8080/webpack-dev-server.js"></script>
    <script src="/main.js"></script>
</body>
</html>

然后在js文件中准备好三要素:renderer, camera, scene


const canvas = document.getElementById('main');

/*画布大小*/
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

/*renderer*/
renderer = new THREE.WebGLRenderer({ canvas });
renderer.shadowMap.enabled = true; //辅助线
renderer.shadowMapSoft = true; //柔和阴影
renderer.setClearColor(0xffffff, 0);

/*scene*/
scene = new THREE.Scene();

/*camera*/
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1 ,1000);
camera.position.set(-200,50,0);
camera.lookAt(new THREE.Vector3(0,0,0));
scene.add(camera);

renderer.render(scene,camera);

举个橘子

现在我们已经准备好一个#fff的画布,以及一个看着(0,0,0)的摄像机了。接下来,我们现在原点设置一个太阳,虽然这个太阳现在看上去只是一个橘子:


/*sun*/
const Sun = new THREE.Mesh( new THREE.SphereGeometry( 12 ,16 ,16 ),
  new THREE.MeshLambertMaterial({
    color: 0xffff00,
    emissive: 0xdd4422
  })
);
Sun.name='Sun';
scene.add(Sun);

一个太阳太孤独了,我们先把地球也放在画布上


const Earth = new THREE.Mesh( new THREE.SphereGeometry( 5, 16,16 ),
  new THREE.MeshLambertMaterial( { color: 'rgb(46,69,119)', emissive: 'rgb(46,69,119)' } )
);
Earth.position.z = -40;
scene.add(Earth);

由于我们还没定义光源,所以先使用emissive来显示球体的颜色。


实际上,太阳系各个行星的轨道是不在一个平面上的。在这个例子的处理里面,我暂时不考虑这一点,假设行星的轨道都在(x, 0, z)的平面上。

举很多个橘子

接下来,就可以此类推添加上太阳系的各个行星了。这里各星体的比例和距离没有遵循实际的数据,因为实际的比例会相当难观察。


let Sun,
  Mercury,  //水星
  Venus,  //金星
  Earth,
  Mars,
  Jupiter, //木星
  Saturn, //土星
  Uranus, //天王
  Neptune, //海王
  stars = [];

module.exports = {

  init(){
      //构造太阳
      ...

      /*planets*/
      Mercury = this.initPlanet('Mercury','rgb(124,131,203)',20,2);
      stars.push(Mercury);

      Venus = this.initPlanet('Venus','rgb(190,138,44)',30,4);
      stars.push(Venus);

      Earth = this.initPlanet('Earth','rgb(46,69,119)',40,5);
      stars.push(Earth);

      Mars = this.initPlanet('Mars','rgb(210,81,16)',50,4);
      stars.push(Mars);

      Jupiter = this.initPlanet('Jupiter','rgb(254,208,101)',70,9);
      stars.push(Jupiter);

      Saturn = this.initPlanet('Saturn','rgb(210,140,39)',100,7);
      stars.push(Saturn);

      Uranus = this.initPlanet('Uranus', 'rgb(49,168,218)',120,4);
      stars.push(Uranus);

      Neptune = this.initPlanet('Neptune','rgb(84,125,204)',150,3);
      stars.push(Neptune);

  },

  /**
   * 初始化行星
   * @param name  行星名字
   * @param color  颜色
   * @param distance  距离原点(太阳中心)的距离
   * @param volume  体积
   * @returns {{name: *, distance: *, volume: *, Mesh: THREE.Mesh}}
   */
  initPlanet(name,color,distance,volume) {
    let mesh = new THREE.Mesh( new THREE.SphereGeometry( volume, 16,16 ),
      new THREE.MeshLambertMaterial( { emissive: color } )
    );
    mesh.position.z = -distance;
    mesh.receiveShadow = true;
    mesh.castShadow = true;

    mesh.name = name;

    let star = {
      name,
      distance,
      volume,
      Mesh : mesh
    }

    scene.add(mesh);

    return star;
  },
}

抽象出了一个方法用于构造行星, 这里所有的星体都使用Lambert材质。


虽然现在看上去像是在打祖玛,但是都会好起来的~

准备动画部分

接下来我们无论想让行星动起来,还是镜头动起来,都涉及到动画的部分。Three推荐通过

requestAnimationFrame()

的方法进行动画,这个方法的调用方式跟setTimeout类似:


function move() {
  //do sth...

  requestAnimationFrame(move)
}

move()

虽然没有指定动画的间隔, 但是这个方法默认以一秒60次(60帧)的频率执行。

requestAnimationFramesetInterval的区别主要在于CPU占用率、浏览器兼容性和卡顿处理等。一般来说,requestAnimationFrame对CPU更友好, 而由于是比较新的方法所以需要注意低版本浏览器的兼容。在卡顿处理上,当浏览器达不到设定的调用周期时,requestAnimationFrame采用跳过某些帧的方式来表现动画,虽然会有卡滞的效果但是整体速度不会拖慢,而setInterval会因此使整个程序放慢运行,但是每一帧都会绘制出来。这里涉及到拖慢和卡顿的取舍问题。

requestAnimationFrame适用于对于时间较为敏感的环境(但是动画逻辑更加复杂),而setInterval则可在保证程序的运算不至于导致延迟的情况下提供更加简洁的逻辑(无需自行处理时间)。

参考 requestAnimationFrame 和开头提到的
张雯莉的Three.js入门指南

第一视觉移动

要加入第一视觉移动的功能,可以选择Three提供的部件firstPersonControls。这个部件需要用script另外引入。使用方法也很简单:


let control;
const clock = new THREE.Clock(); //用于计算两次animationFrame之间间隔时间

init() {
  //...

  /*镜头控制*/
  control = new THREE.FirstPersonControls( camera , canvas);
  control.movementSpeed = 100;  //镜头移速
  control.lookSpeed = 0.125;  //视角改变速度
  control.lookVertical = true;  //是否允许视角上下改变

  renderer.render(scene,camera);
  requestAnimationFrame(()=>this.move());
},

move() {
  control.update( clock.getDelta() );   //此处传入的delta是两次animationFrame的间隔时间,用于计算速度 

  renderer.render(scene,camera);
  requestAnimationFrame(()=>this.move());
}

firstPersonControls通过距离(鼠标移动过的屏幕距离)和时间(通过Clock计算)计算而得出镜头视觉改变的速度,相当于我们站着不动,转动眼睛、头部的视觉改变方式。而镜头本身位置的改变相当于人走路,通过按键监听实现。

现在我们已经可以进行主视觉控制镜头进行游览啦!

来看看这一串完全静止不动的冰糖葫芦:


帧率监视

在初步引入动画的的布置之后,我们最好引入一个帧率监视的工具,以便我们对整个动画的效率的掌握。


<script src="../resource/js/three/stats.min.js"></script>

引入后只需要在js代码初始化,并在move()中更新即可:


init() {
  /*stats帧率统计*/
  /*放置dom*/
  stat = new Stats();
  stat.domElement.style.position = 'absolute';
  stat.domElement.style.right = '0px';
  stat.domElement.style.top = '0px';
  document.body.appendChild(stat.domElement);
  ...
},

move() {
  //...

  stat.update();
}

星体运动的逻辑

我们知道,想用canvas或者Three做点高大上的东西,都离不开数学和图形学。在这一部分当然不会有很复杂的公式之类,但是也要好好的思考一下:

我们需要让星球环绕着太阳(原点)做圆周运动,在只需要考虑平面(x, 0, z)的情况下,实际就是通过三角函数去计算星球的平面位置。


(图自维基百科)

我们设置(0, 0)为太阳中心位置,P点(x, y)则为星体位置。当然了,y值会在实际中作为z的值。

我们给星体设置一个公转的角速度, 每次animationFrame的执行中,我们都为星体累加角度, 通过Math.sin(), Math.cos()即可顺利计算出星体当前的位置。

星体运动动画


/*每一颗行星的公转*/
moveEachStar(star){

  star.angle+=star.speed;
  if (star.angle > Math.PI * 2) {
    star.angle -= Math.PI * 2;
  }

  star.Mesh.position.set(star.distance * Math.sin(star.angle), 0, star.distance * Math.cos(star.angle));
}

其中,我们要给每一个星体加上当前角度和角速度的属性。当角度已经累加到2PI时,此时星体已经走过一圈了,所以可以把无用的2PI去掉。由于动画大概每秒60帧,所以每秒钟大概会累加60*speed。这个方法在move()中为每一个星体都执行一次,就可以真正动起来了。


init() {
  //...
  /*角速度为0.02,初始角度为0*/
  Mercury = this.initPlanet('Mercury',0.02,0,'rgb(124,131,203)',20,2);
  ...

}


initPlanet(name,speed,angle,color,distance,volume,ringMsg) {
  //...

  let star = {
    name,
    speed,
    angle,
    distance,
    volume,
    Mesh : mesh
  }
}

到现在,整个游览太阳系的模型已经出来了。


给星体加上运动轨迹辅助线吧

为了方便观察,使用RingGeometry来制作运动轨迹:


initPlanet() {
  /*轨道*/
  let track = new THREE.Mesh( new THREE.RingGeometry (distance-0.2, distance+0.2, 64,1),
    new THREE.MeshBasicMaterial( { color: 0x888888, side: THREE.DoubleSide } )
  );
  track.rotation.x = - Math.PI / 2;
  scene.add(track);
}

由于Ring默认是垂直于x轴,需要让它进行一次rotate

太阳能电灯泡?

接下来要开始设置光源相关。在这个太阳系的环境中,我们需要用到环境光点光PointLight自不必说,把它放在太阳的中心来模拟太阳发出的亮光,大写的一个太阳能电灯泡。而行星的背面由于不会被太阳光照到,需要环境光AmbientLight来辅助照明。


//环境光
let ambient = new THREE.AmbientLight(0x999999);
scene.add(ambient);

/*太阳光*/
let sunLight = new THREE.PointLight(0xddddaa,1.5,500);
scene.add(sunLight);

其中PointLight的后两个参数代表光照强度和光照影响的距离。接收第三个参数的话就代表光照衰减。


?,是不是感觉不太对?

因为我们之前在构造星体的时候,赋予颜色的属性值是emissive,只需要改回来color即可。

太阳太丑了,拿QB去买个皮肤吧

一个大黄色的咸蛋黄的确不太好看,我们要做的是给太阳加上图片作为材质。


/*sun skin pic*/
let sunSkinPic = THREE.ImageUtils.loadTexture('../resource/img/sunCore.jpg', {}, function() {
  renderer.render(scene, camera);
});
/*sun*/
Sun = new THREE.Mesh( new THREE.SphereGeometry( 12 ,16 ,16 ),
  new THREE.MeshLambertMaterial({
    /*color: 0xffff00,*/
    emissive: 0xdd4422,
    map: sunSkinPic
  })
);

使用THREE.ImageUtils.loadTexture可以加载一个图片作为材质。这一张图片是这样子的:


当我们使用它作为球体的材质时,它会自动进行完全的覆盖。

太阳有了皮肤之后,会很明显发现太阳根本就没有自转。我们可以在move中改变它的rotation.y来让它跑得比谁都快。


/*太阳自转*/
Sun.rotation.y = (Sun.rotation.y == 2*Math.PI ? 0.0008*Math.PI : Sun.rotation.y+0.0008*Math.PI);

低成本高品质有层次感的星星背景,你值得拥有

虽然Three为我们做了很好的优化工作,但是如果我们想for出几万个SphereGeometry做背景的星光,电脑肯定就要跪在地上了。如何合理运用官方的API来做出这一点?

答案就是BufferGeometryBufferGeometry会保存这个Geometry所有的数据,including vertex positions, face indices, normals, colors, UVs, and custom attributes within buffers,从而大量减少GPU的运算压力。
check three.js / documentation . 但是相对的,使用难度也稍微大一点。


/*背景星星*/
const particles = 20000;  //星星数量
/*buffer做星星*/
const bufferGeometry = new THREE.BufferGeometry();

/*32位浮点整形数组*/
let positions = new Float32Array( particles * 3 );
let colors = new Float32Array( particles * 3 );

let color = new THREE.Color();

const gap = 1000; // 定义星星的最近出现位置

首先进行准备工作。每一个Float32Array数组的每三个成员来确定一个行星的信息(位置、颜色)。


for ( let i = 0; i < positions.length; i += 3 ) {
  // positions

  /*-2gap < x < 2gap */
  let x = ( Math.random() * gap *2 )* (Math.random()<.5? -1 : 1);
  let y = ( Math.random() * gap *2 )* (Math.random()<.5? -1 : 1);
  let z = ( Math.random() * gap *2 )* (Math.random()<.5? -1 : 1);

  /*找出x,y,z中绝对值最大的一个数*/
  let biggest = Math.abs(x) > Math.abs(y) ? Math.abs(x) > Math.abs(z) ? 'x' : 'z' :
    Math.abs(y) > Math.abs(z) ? 'y' : 'z';

  let pos = { x, y, z};

  /*如果最大值比n要小(因为要在一个距离之外才出现星星)则赋值为n(-n)*/
  if(Math.abs(pos[biggest]) < gap) pos[biggest] = pos[biggest] < 0 ? -gap : gap;

  x = pos['x'];
  y = pos['y'];
  z = pos['z'];

  positions[ i ]     = x;
  positions[ i + 1 ] = y;
  positions[ i + 2 ] = z;

  // colors
}

要定义一颗星星的位置,首先我们要决定它能够出现的范围。在这里我简单的设置为:以原点为中心,在边长为2000的立方体内不能出现星星,边长为4000的立方体外不能出现,类似于一个空心的立方体。太近的话容易被镜头撞上,太远了镜头也很难捕捉。

在这种情况下,x, y, z三个坐标必须至少有一个坐标是比gap(1000)要大的,这样才能保证不会出现在内层。


// colors
/*70%星星有颜色*/
let hasColor = Math.random() > 0.3;
let vx, vy, vz;

if(hasColor){
    vx = (Math.random()+1) / 2 ;
    vy = (Math.random()+1) / 2 ;
    vz = (Math.random()+1) / 2 ;
}else{
    vx = 1 ;
    vy = 1 ;
    vz = 1 ;
}

color.setRGB( vx, vy, vz );

colors[ i ]     = color.r;
colors[ i + 1 ] = color.g;
colors[ i + 2 ] = color.b;

接下来在for循环中为星星添上颜色。color.setRGB会把0~1的数字转化为0~255的rgb色,这里我选择范围为0.5~1的随机值是因为亮色的星星会比较好看。


bufferGeometry.addAttribute( 'position', new THREE.BufferAttribute( positions, 3 ) );
bufferGeometry.addAttribute( 'color', new THREE.BufferAttribute( colors, 3 ) );
bufferGeometry.computeBoundingSphere();

/*星星的material*/
let material = new THREE.PointsMaterial( { size: 6, vertexColors: THREE.VertexColors } );
particleSystem = new THREE.Points( bufferGeometry, material );
scene.add( particleSystem );

最后设置bufferGeometry, 并让它进行空间计算。


看着终于有一点宇宙的感觉了。在移动镜头时,也能感觉到星星的空间层次感。至此,整个项目已经完成绝大部分了,最后就是收尾的工作。

妈妈这是哪个星球?

太阳系行星傻傻分不清楚。这一部分可以让鼠标指向的行星的名字显示出来。


const raycaster = new THREE.Raycaster();  //指向镭射
const mouse = new THREE.Vector2();  //鼠标屏幕向量

这里引入两个对象,辅助我们对鼠标指向的对象进行捕捉。

move()中加入判断交汇对象的部分:


/*鼠标指向行星显示名字*/
raycaster.setFromCamera( mouse, camera ); 
/*交汇点对像*/
let intersects = raycaster.intersectObjects( scene.children ); 
if( intersects.length > 0 ){
  /*取第一个交汇对像(最接近相机)*/
  let obj = intersects[ 0 ].object;

  let name = obj.name;
}

通过raycaster可以取得鼠标指向的,离屏幕(镜头)最近的一个对象。
要想显示出名字,可以使用TextGeometry


let planetName = new THREE.Mesh( 
  new THREE.TextGeometry( name, {
    size: 4,
    height: 4
  }),
  new THREE.MeshBasicMaterial( { color: 0xffffff, side: THREE.DoubleSide} )
);

通过传入文字和大小信息,就可以在空间中显示出立体的文字。关于文字出现的位置可以这样设置:


/*复制行星位置*/
displayName.position.copy(obj.position);
/*文字居中*/
displayName.geometry.center();
/*显示在行星的上方(y轴)*/
displayName.position.y = starNames[name].volume + 4;
/*面向相机*/
displayName.lookAt(camera.position);

详细代码请参考源码。

完善的部分

上述没说到的内容,还有土星的圆环、太阳的模拟的燃烧层、限制镜头移动空间等,这些都可以轻松的在源码中找到。

End

最后祝大家身体健康

编辑于 2016-05-03

文章被以下专栏收录