three.js学习笔记(十七)——星系动画

介绍

我们一样可以对粒子使用着色器。
在前面学习粒子的时候介绍过,出于性能原因,为几何体的每个顶点都设置动画不是一个有效解决方案。而这便是能让GPU直接通过顶点着色器为顶点设置动画而发挥作用之处。

我们将从一个粒子星系开始,在顶点着色器中设置粒子的动画,使得星系旋转且根据距离中心距离的不同而速度不一样。

初始设置

script.js

import './style.css'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import * as dat from 'dat.gui'

/**
 * Base
 */
// Debug
const gui = new dat.GUI()

// Canvas
const canvas = document.querySelector('canvas.webgl')

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

/**
 * Galaxy
 */
const parameters = {}
parameters.count = 200000
parameters.size = 0.005
parameters.radius = 5
parameters.branches = 3
parameters.spin = 1
parameters.randomness = 0.5
parameters.randomnessPower = 3
parameters.insideColor = '#ff6030'
parameters.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 insideColor = new THREE.Color(parameters.insideColor)
    const outsideColor = 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 = insideColor.clone()
        mixedColor.lerp(outsideColor, 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.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)

/**
 * Sizes
 */
const sizes = {
    width: window.innerWidth,
    height: window.innerHeight
}

window.addEventListener('resize', () =>
{
    // Update sizes
    sizes.width = window.innerWidth
    sizes.height = window.innerHeight

    // Update camera
    camera.aspect = sizes.width / sizes.height
    camera.updateProjectionMatrix()

    // Update renderer
    renderer.setSize(sizes.width, sizes.height)
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
})

/**
 * Camera
 */
// Base camera
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100)
camera.position.x = 3
camera.position.y = 3
camera.position.z = 3
scene.add(camera)

// Controls
const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true

/**
 * Renderer
 */
const renderer = new THREE.WebGLRenderer({
    canvas: canvas
})
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

/**
 * Animate
 */
const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Update controls
    controls.update()

    // Render
    renderer.render(scene, camera)

    // Call tick again on the next frame
    window.requestAnimationFrame(tick)
}

tick()

跟之前学习“创建星系”的时候一样的设置,只不过没有设置动画,因为我们要在着色器中去设置动画效果。

使用着色器材质替换点材质

material = new THREE.ShaderMaterial({
    // ...
})

three.js学习笔记(十七)——星系动画_第1张图片
如果查看控制台,会看到两条警告,告诉我们着色器材质不支持size尺寸大小和sizeAttenuation尺寸衰减,因此要我们自己去添加这些特性,所以现在先移除上边这俩个属性,保留如下三个:

material = new THREE.ShaderMaterial({
    depthWrite: false,
    blending: THREE.AdditiveBlending,
    vertexColors: true
})

操作完毕后一些人可能会看到像小红点一般的粒子,或者看到黑屏。这取决于当你没有提供粒子大小size时GPU会如何处理粒子。
下边添加顶点着色器,提供粒子大小:

material = new THREE.ShaderMaterial({

    // ...

    vertexShader: `
        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;
        }
    `
})

可以看到,我们依次使用modelMatrixviewMatrixprojectionMatrix来更新顶点位置,我们还给一个gl_PointSize赋值2.0,这个设置了粒子的大小为2x2,不论相机距离如何,我们都应该看到2x2大小的粒子。
three.js学习笔记(十七)——星系动画_第2张图片
在这里,我们使用的单位是片元fragment,如果你使用的是像素比为1的普通屏幕,一个粒子将是2像素x2像素,因为1个片元等于1个像素。但如果你使用的是视网膜屏幕这种像素比更高的屏幕,1个片元将小于1个像素,你会看到更小的粒子。在后边我们会修复这个问题。
现在先更改下粒子颜色,粒子当前为红色,因为我们没提供任何片元着色器,Three.js会使用默认红色输出。

添加片元着色器:

material = new THREE.ShaderMaterial({

        // ...

        fragmentShader: `
            void main()
            {
                gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
            }
        `
    })

将着色器移动到独立文件

像前几篇文章一样

import galaxyVertexShader from './shaders/galaxy/vertex.glsl'
import galaxyFragmentShader from './shaders/galaxy/fragment.glsl'

// ...

material = new THREE.ShaderMaterial({
    // ...

    vertexShader: galaxyVertexShader,
    fragmentShader: galaxyFragmentShader
})

处理尺寸

基础大小

我们会先给每一个粒子添加基本大小,并且可以在js中去更改它们。然后在着色器材质的uniforms属性中自定义一个名为uSize的uniform

material = new THREE.ShaderMaterial({
    // ...
    uniforms:
    {
        uSize: { value: 8 }
    },
    // ...
})

回到顶点着色器中,检索该变量并应用在gl_PointSize上:

uniform float uSize;

void main()
{
    // ...
    gl_PointSize = uSize;
}

three.js学习笔记(十七)——星系动画_第3张图片

随机大小

因为星星有着不同大小,因此我们要给每个顶点关联一个随机值(通常使用粒子一般都需要添加随机值,很少有粒子都是相同大小),将会使用到一个attribute
向几何体添加缩放aScales属性,因为已经有了颜色和位置属性,可以仿照着敲:

geometry = new THREE.BufferGeometry()

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

// ...

for(let i = 0; i < parameters.count; i++)
{
    // ...

    // Scale
    scales[i] = Math.random()
}

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1))

注意在创建Float32ArrayBufferAttribute新实例时,确保使用1而不是3,因为它是一个浮点值,而不是像其他俩个值位置和颜色一样是vec3,我们每个顶点只需要一个值就行。注意使用小写a前缀命名该属性。

如果你试着修改位置position和颜色color属性重命名为aPositionaColor,你会发现图案不会正常显示,这是因为我们现在使用的是ShaderMaterial,因为该材质已经预置了这些属性
three.js学习笔记(十七)——星系动画_第4张图片
现在可以在顶点着色器中使用属性aScale

uniform float uSize;

attribute float aScale;

void main()
{
    // ...

    gl_PointSize = uSize * aScale;
}

会看到每个粒子大小不一:
three.js学习笔记(十七)——星系动画_第5张图片

固定像素比

前面说到粒子的大小取决于屏幕的像素比,我们在js中使用了下面这行代码更新渲染器的像素比:

renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

如果你有一个像素比为1的屏幕,粒子看起来会比像素比为2的屏幕大上俩倍。
因此需要一个合适的解决方案,不管像素比为多少,始终获得相同大小的粒子。
最简单的方法便是将uSize与渲染器的像素比相乘,可以用getPixelRatio()来获取该像素比:

material = new THREE.ShaderMaterial({

    // ...

    uniforms:
    {
        uSize: { value: 8 * renderer.getPixelRatio() }
    }

    // ...
})

然后会看到控制台报错,因为我们在创建渲染器之前创建了材质,只需要在实例化渲染器后再调用generateGalaxy()就行。

/**
 * 渲染器
 */
const renderer = new THREE.WebGLRenderer({
    canvas: canvas
})

// ...

/**
 * 生成星系
 */
generateGalaxy()

现在不论像素比如何,粒子在不同屏幕看起来都不会有太大差距。

尺寸衰减

现在不管我们怎么移动相机,看到的粒子的大小都是不变化的,没有近大远小的效果。
我们没有设置sizeAttenuation属性,因为着色器材质不支持,因此我们会自行设置尺寸衰减来模拟透视。
下面直接去到three.js的依赖包里边,在
/node_modules/three/src/renderers/shaders/ShaderLib/point_vert.glsl.js路径下找到处理此处着色器的代码:

#ifdef USE_SIZEATTENUATION

    bool isPerspective = isPerspectiveMatrix( projectionMatrix );

    if ( isPerspective ) gl_PointSize *= ( scale / - mvPosition.z );

#endif

而我们需要的只是这一行:

gl_PointSize *= ( scale / - mvPosition.z );

想要获得尺寸衰减,需要用gl_PointSize去乘以( scale / - mvPosition.z ),而根据three.js,这个scale是与渲染高度相关的值,此处为易于控制,我们会用1.0替代。
mvPosition其实就是modelViewPosition,也就是我们原本代码里边在应用modelMatrixviewMatrix得到后的位置变量viewPosition,因此可以直接在顶点着色器中这样写:

gl_PointSize = uSize * aScale;
gl_PointSize *= (1.0 / - viewPosition.z);

可以看到越靠近摄像机的粒子更大,尺寸衰减应用成功。

给粒子绘制样式

在three.js学习笔记(十五)——着色器图案中,我们在平面上绘制图案时,需要将uv坐标从顶点着色器发送到片元着色器,然而我们现在无法通过varyinguv从顶点着色器发送到片元着色器,因为每一个顶点都是一个粒子。
不过其实我们已经在片元着色器中接收到uv了,通过gl_PointCoord,该变量是粒子特有的。
将其加到片元着色器代码中:

void main()
{
    gl_FragColor = vec4(gl_PointCoord, 1.0, 1.0);
}

可以在每个粒子上看到我们通常所见的uv图案:
three.js学习笔记(十七)——星系动画_第6张图片

圆形

  1. 获取gl_PointCoord 到中心点 ( vec2(0.5) )的距离
  2. 如果距离小于0.5,则用阶跃函数step()返回0.0,如果距离大于0.5,返回1.0
  3. 反转数值,1为可见区域,0为不可见区域

three.js学习笔记(十七)——星系动画_第7张图片

然后我们使用strength替代rgb值:

void main()
{
    // 圆形
    float strength = distance(gl_PointCoord, vec2(0.5));
    strength = step(0.5, strength);
    strength = 1.0 - strength;

    gl_FragColor = vec4(vec3(strength), 1.0);
}

为了更直观理解代码,下边是只到步骤2,没有经过反转值的效果:
three.js学习笔记(十七)——星系动画_第8张图片
下边是反转后的效果:

散射点图形

  1. 获取gl_PointCoord 到中心点 ( vec2(0.5) )的距离
  2. 将之乘以2.0,使其在到达边缘前值就达到1.0
  3. 反转数值
void main()
{
    // 散射点图形
    float strength = distance(gl_PointCoord, vec2(0.5));
    strength *= 2.0;
    strength = 1.0 - strength;

    gl_FragColor = vec4(vec3(strength), 1.0);
}

为了更直观理解代码,下边是只到步骤2,没经过反转值的效果:

下边是反转后的效果:

光点图形

  1. 获取gl_PointCoord 到中心点 ( vec2(0.5) )的距离
  2. 反转数值
  3. pow()函数传一个很高的平方值
void main()
{
    // 光点图形
    float strength = distance(gl_PointCoord, vec2(0.5));
    strength = 1.0 - strength;
    strength = pow(strength, 10.0);

    gl_FragColor = vec4(vec3(strength), 1.0);
}

下边是只到步骤2反转数值后的效果:

下边是应用pow()后得到原强度的十次幂:

通过pow()函数我们可以控制辉光的凝聚程度,幂值越高,越凝聚集中

处理颜色

我们本应该在顶点着色器中检索颜色属性:

attribute vec3 color;

但前面说了,因为着色器材质预置了颜色color属性,所以我们只需要将其传给片元着色器即可。
在顶点着色器中使用一个变量varying命名为vColor,其值即为预置的color值:

// ...

varying vec3 vColor;

void main()
{
    // ...

    /**
     * Color
     */
    vColor = color;
}

回到片元着色器一样声明一个vColor的变量来接收,并用mix()函数根据强度混合黑色vec3(0.0)vColor

varying vec3 vColor;

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

    // 处理颜色
    vec3 color = mix(vec3(0.0), vColor, strength);
    gl_FragColor = vec4(color, 1.0);
}

然后可以得到我们最初的星系效果图了

动画

回到js代码里,在着色器材质的uniforms属性中自定义一个名为uTime的uniform,并在tick()方法里更新其值:

material = new THREE.ShaderMaterial({

    // ...

    uniforms:
    {
        uTime: { value: 0 },
        uSize: { value: 30 * renderer.getPixelRatio() }
    },

    // ...
})

// ...

const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // 更新材质
    material.uniforms.uTime.value = elapsedTime

    // ...
}

uTime添加到顶点着色器中:

uniform float uTime;

下面会让星系旋转起来,离中心越近,旋转速度越快。
下边的代码都是位于modelPosition声明后边,modelPosition是网格应用定位、旋转和缩放后的顶点位置,所以现在要在动画中更新这个变量。

  1. 从星系由上往下看,在x轴和z轴组成的平面上计算粒子的角度,和它们到中心的距离。
  2. 使用uTime和粒子到中心的距离来增加角度,离中心越远,角度变化越慢。
  3. 根据新角度更新位置。

获取角度值

首先,使用反正切atan()函数来获得角度值:

vec4 modelPosition = modelMatrix * vec4(position, 1.0);

// Rotate
float angle = atan(modelPosition.x, modelPosition.z);

可以点击下面链接查看更多关于atan()函数的信息
https://thebookofshaders.com/glossary/?search=atan

获取距离中心的距离值

之后,使用length()函数得到距离中心的长度值:

vec4 modelPosition = modelMatrix * vec4(position, 1.0);

// Rotate
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz);

计算偏转角度量

计算偏转角度,随着时间变化,越靠近中心,偏转角度量就越大,反之离中心越远,偏转角度越小,乘以0.2是为了缓冲角度变化量:

vec4 modelPosition = modelMatrix * vec4(position, 1.0);

// Rotate
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz);
float angleOffset = (1.0 / distanceToCenter) * uTime * 0.2;

添加偏转量

给粒子原本的基角度添加上该偏转角度量:

vec4 modelPosition = modelMatrix * vec4(position, 1.0);

// Rotate
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz);
float angleOffset = (1.0 / distanceToCenter) * uTime * 0.2;
angle += angleOffset;

应用到模型位置上

最后分别用cos()sin()来更新位置modelPosition的x轴和y轴上边的值:

vec4 modelPosition = modelMatrix * vec4(position, 1.0);

// Rotate
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);
modelPosition.z = sin(angle);

因为我们只在x轴和z轴上对粒子角度使用了cos()sin()函数,所以会使它们的位置保持在一个半径为1的圆上边。

需要做的只是简单将该值与粒子到中心的距离相乘:

vec4 modelPosition = modelMatrix * vec4(position, 1.0);

// Rotate
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;

修复随机性

把随机性randomness的值降低到0.2,随着时间推移,上图的星系会变成如下:

可以看到这些粒子在越靠近中心的同时,越是凝聚变成一条环带形状,就像我们在x轴和z轴上为其添加的位置随机值失效一般,这是因为我们的旋转函数使得星星在旋转中进行了拉伸。

回到js中,我们会移除位置position属性中的randomness,并将其保存在一个名为aRandomness的新属性中:

geometry = new THREE.BufferGeometry()

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

// ...

for(let i = 0; i < parameters.count; i++)
{
    // ...

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

    randomness[i3    ] = randomX
    randomness[i3 + 1] = randomY
    randomness[i3 + 2] = randomZ

    // ...
}

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geometry.setAttribute('aRandomness', new THREE.BufferAttribute(randomness, 3))

// ...

然后在顶点着色器开始旋转星系后再应用这个随机性:

// ...

attribute vec3 aRandomness;
attribute float aScale;

 // ...

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

    // Rotate
    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;

    // Randomness
    modelPosition.xyz += aRandomness;

    // ...
}

可以看到靠近中心的环带效果消失:

你可能感兴趣的:(three.js学习笔记,javascript,学习,动画)