阅读 3164

用three.js写一个3D地球

前言

这是学习three.js系列的第四篇,前三篇是:

用three.js写一个下雨动画

用three.js写一个小场景

用three.js写一个反光球

关于three.js基础知识,是放在了第一篇: 用three.js写一个下雨动画,可前往查看,后面的案例都不再重复。

从今天开始学习着色器,下面是一个简单使用着色器动画完成的3D地球。让我们开始吧。

earth-gif-l.gif

着色器的入门介绍

Webgl绘制图形是基于着色器(shader)的绘图机制,着色器提供了灵活且强大的绘制二维或三维图形的方法,所有Webgl程序必须使用它。

着色器语言类似于c语言,当我们写webgl程序时,着色器语言以字符串的形式嵌入在javascript语言中。

比如,要在屏幕上绘制一个点,代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    body {
      margin: 0
    }
  </style>
</head>
<body>
  <canvas id="webgl"></canvas>
</body>
<script>
    //将canvas的大小设置为屏幕大小
    var canvas = document.getElementById('webgl')
    canvas.height = window.innerHeight
    canvas.width = window.innerWidth
    
    //获取webgl绘图上下文
    var gl = canvas.getContext('webgl')
    
    //将背景色设置为黑色
    gl.clearColor(0.0, 0.0, 0.0, 1.0)
    gl.clear(gl.COLOR_BUFFER_BIT)

    //顶点着色器代码(字符串形式)
    var VSHADER_SOURCE = 
    `void main () {
      gl_Position = vec4(0.5, 0.5, 0.0, 1.0);  //点的位置:x: 0.5, y: 0.5, z: 0。齐次坐标
      gl_PointSize = 10.0;                     //点的尺寸,非必须,默认是0
    }`

    //片元着色器代码(字符串形式)
    var FSHADER_SOURCE = 
    `void main () {
      gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);    //点的颜色:四个量分别代表 rgba
    }`
    
    //初始化着色器
    initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)
    
    //绘制一个点,第一个参数为gl.POINTS
    gl.drawArrays(gl.POINTS, 0, 1)
    
    function initShaders(gl, vshader, fshader) {
      var program = createProgram(gl, vshader, fshader);
      if (!program) {
        console.log('Failed to create program');
        return false;
      }
      gl.useProgram(program);
      gl.program = program;
      return true;
    }

    function createProgram(gl, vshader, fshader) {
      var vertexShader = loadShader(gl, gl.VERTEX_SHADER, vshader);
      var fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fshader);
      if (!vertexShader || !fragmentShader) {
          return null;
      }
      var program = gl.createProgram();
      if (!program) {
          return null;
      }
      gl.attachShader(program, vertexShader);
      gl.attachShader(program, fragmentShader);
      gl.linkProgram(program);
      var linked = gl.getProgramParameter(program, gl.LINK_STATUS);
      if (!linked) {
          var error = gl.getProgramInfoLog(program);
          console.log('Failed to link program: ' + error);
          gl.deleteProgram(program);
          gl.deleteShader(fragmentShader);
          gl.deleteShader(vertexShader);
          return null;
      }
      return program;
    }

    function loadShader(gl, type, source) {
      // 创建着色器对象
      var shader = gl.createShader(type);
      if (shader == null) {
          console.log('unable to create shader');
          return null;
      }
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      var compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
      if (!compiled) {
          var error = gl.getShaderInfoLog(shader);
          gl.deleteShader(shader);
          return null;
      }
      return shader;
    }
</script>
</html>
复制代码

上面代码在屏幕右上区域绘制了一个点。

image.png

绘制这个点需要三个必要的信息:位置、尺寸和颜色。

  • 顶点着色器指定点的位置和尺寸。(下面的代码中,gl_Positiongl_PointSizegl_FragColor 都是着色器的内置全局变量。)
var VSHADER_SOURCE = 
`void main () {
    gl_Position = vec4(0.5, 0.5, 0.0, 1.0);   //指定点的位置
    gl_PointSize = 10.0;                      //指定点的尺寸
}`
复制代码
  • 片元着色器指定点的颜色。
var FSHADER_SOURCE = 
`void main () {
   gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);   //指定点的颜色
}`
复制代码

attribute变量 和 uniform变量

上面的例子中,我们直接在着色器中指定了点的位置、尺寸和颜色。而实际操作中,这些信息基本都是由js传递给着色器。

用于 js代码 和 着色器代码 通信的变量是attribute变量uniform变量

使用哪一种变量取决于需要传递的数据本身,attribute变量用于传递与顶点相关的数据,uniform变量用于传递与顶点无关的数据。

下面的例子中,要绘制的点的坐标将由js传入。

  //顶点着色器
var VSHADER_SOURCE = 
`attribute vec4 a_Position;    //声明一个attribute变量a_Position,用于接受js传递的顶点位置
void main () {
  gl_Position = a_Position;    //将a_Position赋值给gl_Position
  gl_PointSize = 10.0;
}`

//片元着色器
var FSHADER_SOURCE = 
`void main () {
  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}`

initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)

//js代码中,获取a_Position的存储位置,并向其传递数据
var a_Position = gl.getAttribLocation(gl.program, 'a_Position')
gl.vertexAttrib3f(a_Position, 0.5, 0.5, 0.0)

gl.drawArrays(gl.POINTS, 0, 1)
复制代码

varying变量

我们从js传给着色器的通常是顶点相关的数据,比如我们要绘制一个三角形,三角形的顶点位置和顶点颜色由js传入。三个顶点的位置可以确定三角形的位置,那么整个三角形的颜色由什么确定呢?

这就需要varying变量出场了。

webgl中的颜色计算:

顶点着色器中,接收js传入的每个顶点的位置和颜色数据。webgl系统会根据顶点的数据,插值计算出,顶点之间区域中,每个片元(可以理解为组成图像的最小渲染点)的颜色值。插值计算由webgl系统自动完成。

计算出的每个片元的颜色值,再传递给 片元着色器片元着色器根据每个片元的颜色值渲染出图像。

顶点着色器片元着色器,传递工作由varying变量完成。

image.png

代码如下。

  • 顶点着色器代码
var VSHADER_SOURCE = 
`attribute vec4 a_Position;    //顶点位置
attribute vec4 a_Color;        //顶点颜色
varying vec4 v_Color;          //根据顶点颜色,计算出三角形中每个片元的颜色值,然后将每个片元的颜色值传递给片元着色器。
void main () {
  gl_Position = a_Position;    
  v_Color = a_Color;         // a_Color 赋值给 v_Color
}`
复制代码
  • 片元着色器代码
var FSHADER_SOURCE = 
`precision mediump float;
varying vec4 v_Color;    //每个片元的颜色值
void main () {
  gl_FragColor = v_Color;
}`
复制代码
  • js代码
var verticesColors = new Float32Array([     //顶点位置和颜色
  0.0, 0.5, 1.0, 0.0, 0.0,      // 第一个点,前两个是坐标(x,y;  z默认是0),后三个是颜色
  -0.5, -0.5, 0.0, 1.0, 0.0,   // 第二个点
  0.5, -0.5, 0.0, 0.0, 1.0     // 第三个点
])

//以下是通过缓冲区向顶点着色器传递顶点位置和颜色
var vertexColorBuffer = gl.createBuffer()  

gl.bindBuffer(gl.ARRAY_BUFFER, vertexColorBuffer)
gl.bufferData(gl.ARRAY_BUFFER, verticesColors, gl.STATIC_DRAW)

var FSIZE = verticesColors.BYTES_PER_ELEMENT
var a_Position = gl.getAttribLocation(gl.program, 'a_Position')
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, FSIZE * 5, 0)
gl.enableVertexAttribArray(a_Position)

var a_Color = gl.getAttribLocation(gl.program, 'a_Color')
gl.vertexAttribPointer(a_Color, 3, gl.FLOAT, false, FSIZE * 5, FSIZE * 2)
gl.enableVertexAttribArray(a_Color)

//绘制一个三角形,第一个参数为gl.TRIANGLES
gl.drawArrays(gl.TRIANGLES, 0, 3)
复制代码

下面最终绘制出来的效果:

image.png

纹理映射的简单理解

在上面的例子中,我们是为每个顶点指定颜色值。

延伸一下,纹理映射是为每个顶点指定纹理坐标,然后webgl系统会根据顶点纹理坐标,插值计算出每个片元的纹理坐标。

然后在片元着色器中,会根据传入的纹理图像,以及每个片元的纹理坐标,取出纹理图像中对应纹理坐标上的颜色值(纹素),作为该片元的颜色值,并进行渲染。

纹理坐标的特点:

  • 纹理图像左下角为原点(0, 0)。
  • 向右为横轴正方向,横轴最大值为 1(图像右边缘)。
  • 向上为纵轴正方向,纵轴最大值为 1(图像上边缘)。

image.png 不管纹理图像的尺寸是多少,纹理坐标的范围都是: x轴:0-1,y轴:0-1

画一个3D地球

使用webgl进行绘制,步骤和API都比较繁琐,所幸我们可以借助three.js

three.js中的ShaderMaterial可以让我们自己定制着色器,直接操作像素。我们只需要理解着色器的基本原理。

开始画地球吧。

基础球体

基础球体的绘制比较简单,用three.js提供的材质就行。关于材质的基础,在 用three.js写一个反光球 有比较详细的介绍。

var loader = new THREE.TextureLoader() 
var group = new THREE.Group() 

//创建本体
var geometry = new THREE.SphereGeometry(20,30,30)   //创建球形几何体
var earthMaterial = new THREE.MeshPhongMaterial({    //创建材质
    map: loader.load( './images/earth.png' ),        //基础纹理
    specularMap: loader.load('./images/specular.png'),  //高光纹理,指定物体表面中哪部分比较闪亮,哪部分相对暗淡
    normalMap:  loader.load('./images/normal.png'),   //法向纹理,创建更加细致的凹凸和褶皱
    normalScale: new THREE.Vector2(3, 3)     
})
var sphere = new THREE.Mesh(geometry, earthMaterial)   //创建基础球体
group.add(sphere)
复制代码

image.png

流动大气

使用ShaderMaterial自定义着色器。大气的流动,是通过每次在requestAnimationFrame渲染循环中改变纹理坐标实现。为了使流动更加自然,加入噪声。

//顶点着色器
var VSHADER_SOURCE = `
  varying vec2 v_Uv;   
  void main () {
    v_Uv = uv;       //顶点纹理坐标
    gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4( position, 1.0 );
  }
`

//片元着色器
var FSHADER_SOURCE = `
  uniform float time;      //时间变量
  uniform sampler2D fTexture;    //大气纹理图像
  uniform sampler2D nTexture;    //噪声纹理图像
  varying vec2 v_Uv;              //片元纹理坐标
  void main () {
    vec2 new_Uv= v_Uv + vec2( 0, 0.02 ) * time;   //向量加法,根据时间变量计算新的纹理坐标
    
    //利用噪声随机使纹理坐标随机化
    vec4 noise_Color = texture2D( nTexture, new_Uv );    
    new_Uv.x += noise_Color.r * 0.2;
    new_Uv.y += noise_Color.g * 0.2;
    
    gl_FragColor = texture2D( fTexture, new_Uv );  //提取大气纹理图像的颜色值(纹素)
  }
`

var flowTexture = loader.load('./images/flow.png')
flowTexture.wrapS = THREE.RepeatWrapping
flowTexture.wrapT = THREE.RepeatWrapping

var noiseTexture = loader.load('./images/noise.png')
noiseTexture.wrapS = THREE.RepeatWrapping
noiseTexture.wrapT = THREE.RepeatWrapping

//着色器材质
var flowMaterial = new THREE.ShaderMaterial({
    uniforms: {
      fTexture: {
        value: flowTexture,  
      },
      nTexture: {
        value: noiseTexture,
      },
      time: {
        value: 0.0
      },
    },
    // 顶点着色器
    vertexShader: VSHADER_SOURCE,
    // 片元着色器
    fragmentShader: FSHADER_SOURCE,
    transparent: true
})
var fgeometry = new THREE.SphereGeometry(20.001,30,30)   //创建比基础球体略大的球状几何体
var fsphere = new THREE.Mesh(fgeometry, flowMaterial)    //创建大气球体
group.add(fsphere)
scene.add( group )
复制代码

创建了group,基础球体和大气球体,都加入到group,作为一个整体,设置转动和位置,都直接修改group的属性。

var clock = new THREE.Clock()
  //渲染循环
var animate = function () {
    requestAnimationFrame(animate)
    var delta = clock.getDelta()
    group.rotation.y -= 0.002       //整体转动
    flowMaterial.uniforms.time.value += delta  //改变uniforms.time的值,用于片元着色器中的纹理坐标计算
    renderer.render(scene, camera)
}

animate()
复制代码

image.png

光晕

创建光晕用的是精灵(Sprite),精灵是一个总是面朝着摄像机的平面,这里用它来模拟光晕,不管球体怎么转动,都看上去始终处于光晕中。

var ringMaterial = new THREE.SpriteMaterial( {  //创建点精灵材质
  map: loader.load('./images/ring.png') 
} )
var sprite = new THREE.Sprite( ringMaterial )    //创建精灵,和普通物体的创建不一样
sprite.scale.set(53,53, 1)             //设置精灵的尺寸
scene.add( sprite )
复制代码

最终效果图:

earth-gif-l.gif

文章分类
前端
文章标签