three.js - Animated Galaxy

  • 本篇文章将要绘制一个具有动画效果的galaxy,关于galaxy的实现可以参考之前的这篇笔记 Galaxy,我们在此基础上做了部分修改
  • We saw that animating each particles is a bad idea, animating the geometry buffer attribute on each frame is a bad idea, it must be have frame rate issue
  • so we're going to animate each particles by using vertex shader
  • Set up
<script setup>
  import * as THREE from 'three'
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
  import * as dat from 'dat.gui'

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

  /**
   * Camera
  */
  const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    100
  )
  camera.position.set(3, 3, 3)

  /**
   * Renderer
  */
  const renderer = new THREE.WebGLRenderer()
  renderer.setSize(window.innerWidth, window.innerHeight)
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  document.body.appendChild(renderer.domElement)

  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight
    camera.updateProjectionMatrix()

    renderer.setSize(window.innerWidth, window.innerHeight)
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  })

  /**
   * control
  */
  const controls = new OrbitControls(camera, renderer.domElement)
  controls.enableDamping = true

  /**
   * render
  */
  const clock = new THREE.Clock()
  function tick () {
    let elapsedTime = clock.getElapsedTime()

    controls.update()
    requestAnimationFrame(tick)
    renderer.render(scene, camera)
  }
  tick()
</script>
  • 基于之前已实现的 Galaxy 修改后的 Galaxy,添加对应属性的gui
  /**
   * Galaxy
  */
  // 参数
  const parameters = {
    count: 200000,
    size: 0.005,
    radius: 5,  // 星系半径
    branches: 3,  // 星系分支,平分星系角度
    spin: 1,  // 旋转系数,geometry距离原点越远旋转角度越大
    randomness: 0.5,  // 随机性
    randomnessPower: 3,  // 随机性系数,可控制曲线变化
    insideColor: '#ff6030',
    outsideColor: '#1b3984',
  }

  let geometry = null
  let material = null
  let points = null

  const generateGalaxy = () => {
    if(points !== null) {
      geometry.dispose()
      material.dispose()
      scene.remove(points)
    }
    // geometry
    geometry = new THREE.BufferGeometry()

    const positions = new Float32Array(parameters.count * 3)
    const colors = new Float32Array(parameters.count * 3)

    const colorInside = new THREE.Color(parameters.insideColor)
    const colorOutside = new THREE.Color(parameters.outsideColor)

    for(let i = 0; i < parameters.count; i++) {
      const i3 = i * 3 

      // Position
      const radius = Math.random() * parameters.radius  
      const branchAngle = (i % parameters.branches) / parameters.branches * Math.PI * 2 

      const randomX = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : - 1) * parameters.randomness * radius
      const randomY = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : - 1) * parameters.randomness * radius
      const randomZ = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : - 1) * parameters.randomness * radius

      positions[i3] = Math.cos(branchAngle) * radius + randomX
      positions[i3 + 1] = randomY
      positions[i3 + 2] = Math.sin(branchAngle) * radius + randomZ

      // Color
      const mixedColor = colorInside.clone()
      mixedColor.lerp(colorOutside, radius / parameters.radius)

      colors[i3] = mixedColor.r
      colors[i3 + 1] = mixedColor.g
      colors[i3 + 2] = mixedColor.b
    }
    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
    geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))

    // material
    material = new THREE.PointsMaterial({
      size: parameters.size,
      sizeAttenuation: true,
      depthWrite: false,
      blending: THREE.AdditiveBlending,
      vertexColors: true,
    })

    // Points
    points = new THREE.Points(geometry, material) 
    scene.add(points)
  }
  generateGalaxy()
  /**
   * gui
  */
  const gui = new dat.GUI()
  gui.add(parameters, 'count')
    .min(100)
    .max(1000000)
    .step(100)
    .onFinishChange(generateGalaxy)
  gui.add(parameters, 'radius')
    .min(0.01)
    .max(20)
    .step(0.01)
    .onFinishChange(generateGalaxy)
  gui.add(parameters, 'branches')
    .min(2)
    .max(20)
    .step(1)
    .onFinishChange(generateGalaxy)
  gui.add(parameters, 'randomness')
    .min(0)
    .max(2)
    .step(0.001)
    .onFinishChange(generateGalaxy)
  gui.add(parameters, 'randomnessPower')
    .min(1)
    .max(10)
    .step(0.001)
    .onFinishChange(generateGalaxy)
  gui.addColor(parameters, 'insideColor').onFinishChange(generateGalaxy)
  gui.addColor(parameters, 'outsideColor').onFinishChange(generateGalaxy)
修改后的最初的galaxy.png
  • The first, we're going to create our own shader, we're going to replace PointsMaterial by ShaderMaterial
    • we get a warning telling us that the ShaderMaterial supports neither size nor sizeAttenuation, so remove these properties
    // material
    material = new THREE.ShaderMaterial({
      depthWrite: false,
      blending: THREE.AdditiveBlending,
      vertexColors: true,
    })
warning.png
ShaderMaterial.png
  • Shader
  import vertexShader from './animated-galaxy/vertex.glsl'
  import fragmentShader from './animated-galaxy/fragment.glsl'

    // material
    material = new THREE.ShaderMaterial({
      depthWrite: false,
      blending: THREE.AdditiveBlending,
      vertexColors: true,
      vertexShader,
      fragmentShader,
    })
// vertex.glsl
void main () {
  // Position
  vec4 modelPosition =  modelMatrix * vec4(position, 1.0);
  vec4 viewPosition = viewMatrix * modelPosition;
  vec4 projectedPosition = projectionMatrix * viewPosition;

  gl_Position = projectedPosition;

  // Size
  gl_PointSize = 2.0; // 内置变量,fragment size
}
// fragment.glsl
void main () {
  gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
shader.png
  • Handle the Size
    • because we're not use the PointsMaterial, we need to redo the color, the size, the size attenuation...
      // material
      material = new THREE.ShaderMaterial({
        ...
        uniforms: {
          uSize: {value: 8}
        }
      })
    
    // vertex.glsl
    uniform float uSize;
    
    void main () {
      // Position
      ...
    
      // Size
      gl_PointSize = uSize; // fragment size
    }
    
    • in real life, stars have different sizes, we can send a random value in the attributes
    const generateGalaxy = () => {
      ...
      ...
      const scales = new Float32Array(parameters.count)
    
      for(let i = 0; i < parameters.count; i++) {
          ...
          ...
          // Size
          scales[i] = Math.random()
      }
      ...
      geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1))
    }
    
// vertex.glsl
uniform float uSize;

attribute float aScale;

void main () {
  // Position
  ...

  // Size
  gl_PointSize = uSize * aScale; // fragment size
}
Size.png
  • Fix the pixel ratio
    • the gl_PointSize means the fragment size(这里最好要理解下设备像素比的概念, 即物理像素/逻辑像素)
    • if you have a screen with a pixel ratio of 1, the particles will look 2 times larger than if the screen with a pixel ratio of 2
    • 浏览器获取设备像素比的方法是window.devicePixelRatio,但我们在构造render的部分已经设置过了renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
    // material
    material = new THREE.ShaderMaterial({
      ...
      uniforms: {
        uSize: {value: 8 * renderer.getPixelRatio()}
      }
    })

  /**
   * Renderer
  */
  ...

  window.addEventListener('resize', () => {
    ...
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  })
  generateGalaxy()  // 将调用移动至renderer后面,防止上面在获取renderer变量时报错
  • Size Attenuation
    • 当我们只关注一颗particle时,会发现不管页面放大或是缩小,particle始终保持着相同大小
    • 我们当前使用的perspectiveCamera,呈现效果应当是particle距离camera越远,尺寸越小,这就是size attenuation
    • 原先使用PointsMaterial时,直接设置sizeAttenuation: true就可以了,但在shader中我们需要自己实现这一部分
    • we're going to take the code from the three.js depedencyPointsMaterial的顶点着色器文件路径: /node_modules/three/src/renderers/shaders/ShaderLib/points.glsl.js,我们需要关注的就是以下这段
      #ifdef USE_SIZEATTENUATION
          // 只有在perspectiveCamera下才会工作
          bool isPerspective = isPerspectiveMatrix( projectionMatrix );
    
          // scale is a value related to the renderer hight
          // particle的大小会根据屏幕的高度拉伸,这样不管是在大屏幕还是小屏幕,看到的particle都会差不多
    
          // mvPosition correspond to the position of the  vertex once the modelMatrix and the viewMatrix
          // in our case, it's viewPosition
          if ( isPerspective ) gl_PointSize *= ( scale / - mvPosition.z );
    
      #endif
    
    • 基于以上的代码来修改我们自己的,然后就会发现距离camare近的particle尺寸相对远的较大,这就是size attenuation,但我们在这部分并不想让particle随着屏幕大小的改变而拉伸
    // vertex.glsl
    ...
    void main () {
      ...
      ...
      // Size
      gl_PointSize = uSize * aScale; // fragment size
      gl_PointSize *= ( 1.0 / - viewPosition.z );
    }
    
Size attenuation.png
  • To draw our particle pattern
    • 通常是可以使用Meshuv属性来实现一些图案的,但在这里,我们使用的是new THREE.Points()Points对象是用于渲染大量的点的对象,他不具有uv属性,uv属性通常与Mesh一起使用,用于纹理映射
    • 在当前文件结构里,我们每一个vertex就代表一个particlefragment当然也一样,所以我们使用了内置变量gl_PointCoord
    • gl_PointCoord是一个二维向量,x 和 y 的取值范围都是[0, 1],表示当前片段在 Point Spirit 上的归一化坐标位置
    • 如下,在fragment中测试一下,看看得到的结果,同时再继续实现更多的形状
    // fragment.glsl
    void main () {
      gl_FragColor = vec4(gl_PointCoord, 1.0, 1.0);
    }
    
gl_PointCoord.png
  • Disc Pattern,边缘清晰的圆形
// fragment.glsl
void main () {
  // disc
  float strength = distance(gl_PointCoord, vec2(0.5));   // 计算当前片段与圆心(0.5, 0.5)的距离
  strength = step(0.5, strength);
  strength = 1.0 - strength;

  gl_FragColor = vec4(vec3(strength), 1.0);
}
Disc Pattern.png
  • Diffuse Point Pattern,边缘扩散的particle
// fragment.glsl
void main () {
  // diffuse point
  float strength = distance(gl_PointCoord, vec2(0.5));
  strength *= 2.0;
  strength = 1.0 - strength;

  gl_FragColor = vec4(vec3(strength), 1.0);
}
Diffuse Point Pattern.png
  • Light Point Pattern
// fragment.glsl
void main () {
  // light point
  float strength = distance(gl_PointCoord, vec2(0.5));
  strength = 1.0 - strength;
  strength = pow(strength, 10.0);

  gl_FragColor = vec4(vec3(strength), 1.0);
}
  material = new THREE.ShaderMaterial({
    ...
    ...
    uniforms: {
      uSize: {value: 30 * renderer.getPixelRatio()}
    }
  })
Light Point Pattern.png
  • Handle the Color
// vertex.glsl
...
varying vec3 vColor;

void main () {
  ...
  ...
  // Color
  vColor = color;
}
// fragmrnt.glsl
varying vec3 vColor;

void main () {
  // light point
  float strength = distance(gl_PointCoord, vec2(0.5));
  strength = 1.0 - strength;
  strength = pow(strength, 10.0);

  // Color
  vec3 color = mix(vec3(0.0), vColor, strength);  // 将黑色与color混合
  gl_FragColor = vec4(color, 1.0);
}
Handle the Color.png
  • Animate
    • we only need to rotate the particles on x and z
    • we calculate the particle angle and its distance to the center 我们计算particle和x轴之间的夹角以及particle到中心点的距离
    • we increase that angle according to the uTime and distance
    • we can use atan() to retrieve the angle 用于计算给定数值的反正切值
    // material
    material = new THREE.ShaderMaterial({
      ...
      uniforms: {
        uSize: {value: 30 * renderer.getPixelRatio()},
        uTime: {value: 0}
      }
    })
  /**
   * render
  */
  const clock = new THREE.Clock()
  function tick () {
    let elapsedTime = clock.getElapsedTime()

    // update material
    material.uniforms.uTime.value = elapsedTime
    ...
  }
  tick()
// vertex.glsl
uniform float uSize;
uniform float uTime;
...

void main () {
  // Position
  vec4 modelPosition =  modelMatrix * vec4(position, 1.0);

  float angle = atan(modelPosition.x, modelPosition.z);
  float distanceToCenter = length(modelPosition.xz);
  float angleOffset = (1.0 / distanceToCenter) * uTime * 0.2;
  angle += angleOffset;
  modelPosition.x = cos(angle) * distanceToCenter;
  modelPosition.z = sin(angle) * distanceToCenter;
  ...
  ...
}
Animate.png
  • 增加旋转后的随机性
    • 在创建galaxy时设置了一个参数randomness,只在设置初始position时使用了,旋转过程中却没有使用
    • 旋转过程中particle分布的随机性不好的话,就容易变得越来越线条,particle不能很好的分布在轴线附近,可以对比上下两张图,还是较为直观的
const generateGalaxy = () => {
  ...
  ...
  const randomness = new Float32Array(parameters.count * 3)
  ...
  for(let i = 0; i < parameters.count; i++) {
      ...
      // Position
      const radius = Math.random() * parameters.radius  
      const branchAngle = (i % parameters.branches) / parameters.branches * Math.PI * 2 

      positions[i3] = Math.cos(branchAngle) * radius
      positions[i3 + 1] = 0
      positions[i3 + 2] = Math.sin(branchAngle) * radius

      // random
      const randomX = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : - 1) * parameters.randomness * radius
      const randomY = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : - 1) * parameters.randomness * radius
      const randomZ = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : - 1) * parameters.randomness * radius

      randomness[i3] = randomX
      randomness[i3 + 1] = randomY
      randomness[i3 + 2] = randomZ
      ...
      ...
  } 
  ...
  geometry.setAttribute('aRandomness', new THREE.BufferAttribute(randomness, 3))
  ...
  ...
}
// vertex.glsl
...
attribute vec3 aRandomness;
...
void main () {
    // Position
    ...
    ...
    // random
    modelPosition.xyz += aRandomness;
    ...
    ...
}
randomness.png
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容