我们一样可以对粒子使用着色器。
在前面学习粒子的时候介绍过,出于性能原因,为几何体的每个顶点都设置动画不是一个有效解决方案。而这便是能让GPU直接通过顶点着色器为顶点设置动画而发挥作用之处。
我们将从一个粒子星系开始,在顶点着色器中设置粒子的动画,使得星系旋转且根据距离中心距离的不同而速度不一样。
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({
// ...
})
如果查看控制台,会看到两条警告,告诉我们着色器材质不支持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;
}
`
})
可以看到,我们依次使用modelMatrix
、viewMatrix
和projectionMatrix
来更新顶点位置,我们还给一个gl_PointSize
赋值2.0
,这个设置了粒子的大小为2x2,不论相机距离如何,我们都应该看到2x2大小的粒子。
在这里,我们使用的单位是片元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;
}
因为星星有着不同大小,因此我们要给每个顶点关联一个随机值(通常使用粒子一般都需要添加随机值,很少有粒子都是相同大小),将会使用到一个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))
注意在创建Float32Array
和BufferAttribute
新实例时,确保使用1而不是3,因为它是一个浮点值,而不是像其他俩个值位置和颜色一样是vec3
,我们每个顶点只需要一个值就行。注意使用小写a
前缀命名该属性。
如果你试着修改位置position
和颜色color
属性重命名为aPosition
或aColor
,你会发现图案不会正常显示,这是因为我们现在使用的是ShaderMaterial,因为该材质已经预置了这些属性
现在可以在顶点着色器中使用属性aScale
:
uniform float uSize;
attribute float aScale;
void main()
{
// ...
gl_PointSize = uSize * aScale;
}
前面说到粒子的大小取决于屏幕的像素比,我们在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
,也就是我们原本代码里边在应用modelMatrix
和viewMatrix
得到后的位置变量viewPosition
,因此可以直接在顶点着色器中这样写:
gl_PointSize = uSize * aScale;
gl_PointSize *= (1.0 / - viewPosition.z);
可以看到越靠近摄像机的粒子更大,尺寸衰减应用成功。
在three.js学习笔记(十五)——着色器图案中,我们在平面上绘制图案时,需要将uv
坐标从顶点着色器发送到片元着色器,然而我们现在无法通过varying
将uv
从顶点着色器发送到片元着色器,因为每一个顶点都是一个粒子。
不过其实我们已经在片元着色器中接收到uv
了,通过gl_PointCoord
,该变量是粒子特有的。
将其加到片元着色器代码中:
void main()
{
gl_FragColor = vec4(gl_PointCoord, 1.0, 1.0);
}
gl_PointCoord
到中心点 ( vec2(0.5) )
的距离step()
返回0.0,如果距离大于0.5,返回1.0然后我们使用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,没有经过反转值的效果:
下边是反转后的效果:
gl_PointCoord
到中心点 ( vec2(0.5) )
的距离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,没经过反转值的效果:
下边是反转后的效果:
gl_PointCoord
到中心点 ( vec2(0.5) )
的距离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
是网格应用定位、旋转和缩放后的顶点位置,所以现在要在动画中更新这个变量。
x
轴和z
轴组成的平面上计算粒子的角度,和它们到中心的距离。uTime
和粒子到中心的距离来增加角度,离中心越远,角度变化越慢。首先,使用反正切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;
// ...
}
可以看到靠近中心的环带效果消失: