书接上文,除了网格Mesh
之外,three.js
支持另一种物体,那就是这次的主题之一:粒子。
粒子
之前提到的网格,是一个用三角形构成的实体;但是粒子,就是由许多的颗粒组成的一个比价分散的结构
我们要创建一个例子,只需要把之前的Mesh
改成Points
就可以了。
const points = new THREE.Points(geometry, material);
this.scene.add(points);
但是要注意,在顶点着色器中,我们还要给粒子的大小设置一下,通过的变量是gl_PointSize
。
vertexShader: /* glsl */ `
uniform float iTime;
uniform vec3 iResolution;
uniform vec4 iMouse;
varying vec2 vertexUv;
void main(){
vec3 p = position;
gl_PointSize=10.;
vertexUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
}
`,
现在就可以看到粒子了,这就是默认的样子,但是这样不是很好看,我们可以根据学的 uv 的基础知识里的发光效果来制作发光的粒子。
const material = new THREE.ShaderMaterial({
vertexShader: /* glsl */ `
uniform float iTime;
uniform vec3 iResolution;
uniform vec4 iMouse;
varying vec2 vertexUv;
void main(){
vec3 p = position;
gl_PointSize=10.;
vertexUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
}
`,
fragmentShader: /* glsl */ `
uniform float iTime;
uniform vec3 iResolution;
uniform vec4 iMouse;
varying vec2 vertexUv;
void main() {
vec2 uv = gl_PointCoord;
uv=(uv-.5)*2.;
float d=length(uv);
float c=.05/d;
c=pow(c,2.);
gl_FragColor=vec4(vec3(1.),c);
}
`,
transparent: true,
});
结束了之后大概长这样:
学到这里,我有几个疑问,这边记录一下:
为什么我使用的是
gl_PointCoord
而不是vertexUv
?
这主要与渲染点的方式以及gl_PointCoord
的用途有关:- gl_PointCoord
是一个内置的变量,也是Points
的专有变量,在片元着色器专门用于处理点精灵(point sprites)
。点精灵是一种使用点的几何图元进行渲染的方法。每个点精灵在渲染时会被扩展为一个正方形(通常用于粒子效果)。这个变量就提供了片元着色器当前片元在点精灵内的坐标,范围是[0.0, 1.0]。 - vertexUv
这里的变量是我们自定义的,通过顶点着色器传递给片段着色器的UV
坐标。在使用普通用单元(平面,球体)的时候,我们通常使用 UV 坐标在片元着色器中进行纹理采样和其他的操作。
- gl_PointCoord
为什么不再使用比例修正代码
uv.x*=iResolution.x/iResolution.y;
了?- 因为这个形状
Points
是长宽相等的,默认不用修正比例
- 因为这个形状
transparent
是什么?- 因为
three.js
的默认材质不是透明的,为了让透明度设置生效,我们要手动在材质中开启透明transparent
。设置为true
- 因为
为什么计算结果
c
放在了透明度的第四维度?3D
世界下,shader
更多地关注透明度的控制,因此计算出的值用作alpha
通道,颜色值固定。
现在,我们的透明度也有了,但是整体还是比较暗淡,咱们来给材质加两个参数吧:
- blending: THREE.AdditiveBlending
- depthWrite: false
来解释一下这两个的作用:
- blending: THREE.AdditiveBlending
混合模式,控制材质的混合模式,也就是渲染对象与背景或者其他对象如何混合。而AdditiveBlending
就是加法混合模式。新绘制的颜色值会与已有的颜色值相加。在我们的粒子球体中,产生了一个累加的光效,让它看起来像是在发光。 - depthWrite: false
depthWrite 属性控制材质是否会写入深度缓冲区(Depth Buffer)。渲染对象不会写入深度缓冲区。这意味着即使这个对象在场景中的位置较远,它也不会影响其他对象的深度排序。这种设置通常用于需要重叠或半透明效果的对象,使其不干扰场景中其他对象的渲染顺序。在我们的球体中,关闭深度写入使得点不会阻挡或覆盖其他点,无论它们的绘制顺序如何。这对创建透明或半透明效果特别有用,因为它确保了所有点都能正确显示,而不会因深度冲突而被错误遮挡。
我们再把gl_PointSize
设置大一点,设置为20.
,这样一来,我们的球体就更加的明显了,长这样:
但是现在有一个问题,如果我拉近摄像机,我们就几乎无法看清楚近处的粒子了,因为就算视角变了,但是粒子的大小依然是一个固定值,没有随之变化,我们该怎么解决这个问题呢?
理想的情况应该是:越靠近相机的粒子越大,远离相机的粒子越小,我们需要对gl_PointSize
做一个矩阵变化的操作。这个操作在 three.js
封装的 shader
里面有提到过。我们可以稍微修改一下。
gl_PointSize=50.;
vec4 mvPosition=modelViewMatrix*vec4(p,1.);
gl_PointSize*=(1./-mvPosition.z);
接下来我来讲解一下这个步骤的原理:
之前的一篇关于坐标系的文章里有提到过转换的过程。其中,讲到了物体坐标系到摄像机坐标系的转换,其实vec4 mvPosition=modelViewMatrix*vec4(p,1.);
这一步就是还原这个转换过程的。
而gl_PointSize *= (1. / -mvPosition.z)
这行代码进行视角缩放调整。具体来说,它根据顶点到相机的距离来调整点的大小,以实现透视缩放效果。
- mvPosition.z:顶点在视图坐标系中的 z 坐标,表示顶点到相机的距离。这个值通常是负的,因为在摄像机坐标系中,相机朝向 -Z 方向。
- 1.0 / -mvPosition.z:计算视角缩放因子。距离越远,缩放因子越小,点的大小也就越小,反之亦然。
- gl_PointSize = 50.:这一步就是把粒子调大一点这样我们可以看的更清楚一些
现在我们就看的比较清楚了!近点的粒子
那么我们还剩下最后一个问题,粒子的大小是跟屏幕的像素比有关的,这会导致我们在不同尺寸的屏幕上看到粒子大小会不同。
现象就如同这张图:
该怎么办?
我们需要获取像素比,作为一个uniform
变量传入到Shader
内
const material = new THREE.ShaderMaterial({
...,
uniforms: {
uPixelRatio: {
value: this.renderer.getPixelRatio(),
},
},
});
在顶点着色器中,顶部声明像素比的uniform
变量,并将它与微粒大小相乘即可。
uniform float iTime;
uniform vec3 iResolution;
uniform vec4 iMouse;
uniform float uPixelRatio;
varying vec2 vertexUv;
void main(){
vec3 p = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
gl_PointSize=50. * uPixelRatio;
vertexUv = uv;
vec4 mvPosition = modelViewMatrix * vec4(p, 1.);
gl_PointSize *= (1.0 / - mvPosition.z);
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
}
但是需要声明一点,正常来说不会出现像素比可以任意调节的情况,这里用开发者工具演示也只是为了说明有不同设备之间 Shader 渲染出来有差别这个现象。
在这里贴上整个代码:
<!DOCTYPE html>
<html lang="en">
<style>
body {
margin: 0;
background: black;
}
</style>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vertexShader</title>
</head>
<body>
<div id="sketch"></div>
<script src="https://unpkg.com/kokomi.js@1.9.78/build/kokomi.umd.js"></script>
<script src="https://unpkg.com/three@0.154.0/build/three.min.js"></script>
<script>
class Sketch extends kokomi.Base {
create() {
this.camera.position.set(0, 0, 5);
new kokomi.OrbitControls(this);
const geometry = new THREE.SphereGeometry(2, 64, 64);
// const geometry = new THREE.PlaneGeometry(4, 4);
const material = new THREE.ShaderMaterial({
vertexShader: /* glsl */ `
uniform float iTime;
uniform vec3 iResolution;
uniform vec4 iMouse;
uniform float uPixelRatio;
varying vec2 vertexUv;
void main(){
vec3 p = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
gl_PointSize=50. * uPixelRatio;
vertexUv = uv;
vec4 mvPosition = modelViewMatrix * vec4(p, 1.);
gl_PointSize *= (1.0 / - mvPosition.z);
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
}
`,
fragmentShader: /* glsl */ `
uniform float iTime;
uniform vec3 iResolution;
uniform vec4 iMouse;
varying vec2 vertexUv;
void main() {
vec2 uv = gl_PointCoord;
uv=(uv-.5)*2.;
float d=length(uv);
float c=.05/d;
c=pow(c,2.);
gl_FragColor=vec4(vec3(1.),c);
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
uniforms: {
uPixelRatio: {
value: this.renderer.getPixelRatio(),
},
},
});
const points = new THREE.Points(geometry, material);
this.scene.add(points);
const uj = new kokomi.UniformInjector(this);
material.uniforms = {
...material.uniforms,
...uj.shadertoyUniforms,
};
this.update(() => {
uj.injectShadertoyUniforms(material.uniforms);
});
}
}
const sketch = new Sketch("#sketch");
sketch.create();
</script>
</body>
</html>
自定义形状
我们现在粒子形成的几何体是一个球体,我们有没有什么办法可以把它改成一个自定义的形状?
当然是有的!
在顶点着色器中,有一个变量position
,之前我们就提到过,这个变量有多么的重要,我们可以对他进行各种各样的魔改,因为它是一个attribute
变量。这个变量是一个用于存储顶点信息的变量,顶点信息主要包括位置、颜色等等。
如果你想声明一个attribute
变量,你可以这么写:
attribute vec3 position;
但是three.js
里面的ShaderMaterial
已经内置了这个声明,所以我们不需要再写第二遍。不过嘛,如果你传入了别的attribute
变量的时候,还是需要去生声明一下的。
回到正题,如果我们要创建一个自定义的形状,也就意味着,我们要创建一个新的几何体,并且还要改变这个几何体的位置信息,也就是改变position
这个attribute
变量。
那么我们就要用到:BufferGeometry 来创建自定义的几何体。
const geometry = new THREE.BufferGeometry();
构造一个用于存储位置信息的Array
数组,里面填充随机的数据,为了让亮点看起来多一点,我们把数据设置大一点。
const count = 5000;
let positions = Array.from({ length: count }, () =>
[1, 1, 1].map(THREE.MathUtils.randFloatSpread)
);
console.log(positions);
创建了 5000 个位置数据,每个数据都有三个维度,用three.js
内置的数学函数THREE.MathUtils.randFloatSpread(range)
生成随机分布于(-range/2, range/2)
之间的值(这里是(-0.5, 0.5)
),我们把结果打印一下吧:
oh,我们需要的可不是二维数据,让我们来用flat
函数把它扁平化:
positions = positions.flat();
现在就对了!
接下来我们把数组转换为Float32Array
类型,这个类型是WebGL
缓冲区所支持的类型。
positions = Float32Array.from(positions);
之后我们再将位置信息通过一个方法传递给geometry
里。
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
这样一来,我们就得到了星海一样的内容了。
但是我们并不需要如此茂密的粒子,我们把数据调整一下:
// 拉近摄像头
this.camera.position.set(0, 0, 1);
...
// 将粒子数量限制到250
const count = 250;
// 将range扩大到2
let positions = Array.from({ length: count }, () =>
[2, 2, 2].map(THREE.MathUtils.randFloatSpread)
);
我们还是可以像第一节一样,加上噪点来扭曲顶点位置p
,这样一来可以营造一种生机的感觉。
全部代码:
<!DOCTYPE html>
<html lang="en">
<style>
body {
margin: 0;
background: black;
}
</style>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bufferGeometry</title>
</head>
<body>
<div id="sketch"></div>
<script src="https://unpkg.com/kokomi.js@1.9.78/build/kokomi.umd.js"></script>
<script src="https://unpkg.com/three@0.154.0/build/three.min.js"></script>
<script>
const count = 250;
let positions = Array.from({ length: count }, () =>
[2, 2, 2].map(THREE.MathUtils.randFloatSpread)
);
positions = positions.flat();
console.log(positions);
class Sketch extends kokomi.Base {
create() {
this.camera.position.set(0, 0, 1);
new kokomi.OrbitControls(this);
positions = Float32Array.from(positions);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3)
);
// const geometry = new THREE.PlaneGeometry(4, 4);
const material = new THREE.ShaderMaterial({
vertexShader: /* glsl */ `
uniform float iTime;
uniform vec3 iResolution;
uniform vec4 iMouse;
uniform float uPixelRatio;
varying vec2 vertexUv;
// GLSL textureless classic 3D noise "cnoise",
// with an RSL-style periodic variant "pnoise".
// Author: Stefan Gustavson (stefan.gustavson@liu.se)
// Version: 2011-10-11
//
// Many thanks to Ian McEwan of Ashima Arts for the
// ideas for permutation and gradient selection.
//
// Copyright (c) 2011 Stefan Gustavson. All rights reserved.
// Distributed under the MIT license. See LICENSE file.
// https://github.com/ashima/webgl-noise
//
vec3 mod289(vec3 x)
{
return x-floor(x*(1./289.))*289.;
}
vec4 mod289(vec4 x)
{
return x-floor(x*(1./289.))*289.;
}
vec4 permute(vec4 x)
{
return mod289(((x*34.)+1.)*x);
}
vec4 taylorInvSqrt(vec4 r)
{
return 1.79284291400159-.85373472095314*r;
}
vec3 fade(vec3 t){
return t*t*t*(t*(t*6.-15.)+10.);
}
// Classic Perlin noise
float cnoise(vec3 P)
{
vec3 Pi0=floor(P);// Integer part for indexing
vec3 Pi1=Pi0+vec3(1.);// Integer part + 1
Pi0=mod289(Pi0);
Pi1=mod289(Pi1);
vec3 Pf0=fract(P);// Fractional part for interpolation
vec3 Pf1=Pf0-vec3(1.);// Fractional part - 1.0
vec4 ix=vec4(Pi0.x,Pi1.x,Pi0.x,Pi1.x);
vec4 iy=vec4(Pi0.yy,Pi1.yy);
vec4 iz0=Pi0.zzzz;
vec4 iz1=Pi1.zzzz;
vec4 ixy=permute(permute(ix)+iy);
vec4 ixy0=permute(ixy+iz0);
vec4 ixy1=permute(ixy+iz1);
vec4 gx0=ixy0*(1./7.);
vec4 gy0=fract(floor(gx0)*(1./7.))-.5;
gx0=fract(gx0);
vec4 gz0=vec4(.5)-abs(gx0)-abs(gy0);
vec4 sz0=step(gz0,vec4(0.));
gx0-=sz0*(step(0.,gx0)-.5);
gy0-=sz0*(step(0.,gy0)-.5);
vec4 gx1=ixy1*(1./7.);
vec4 gy1=fract(floor(gx1)*(1./7.))-.5;
gx1=fract(gx1);
vec4 gz1=vec4(.5)-abs(gx1)-abs(gy1);
vec4 sz1=step(gz1,vec4(0.));
gx1-=sz1*(step(0.,gx1)-.5);
gy1-=sz1*(step(0.,gy1)-.5);
vec3 g000=vec3(gx0.x,gy0.x,gz0.x);
vec3 g100=vec3(gx0.y,gy0.y,gz0.y);
vec3 g010=vec3(gx0.z,gy0.z,gz0.z);
vec3 g110=vec3(gx0.w,gy0.w,gz0.w);
vec3 g001=vec3(gx1.x,gy1.x,gz1.x);
vec3 g101=vec3(gx1.y,gy1.y,gz1.y);
vec3 g011=vec3(gx1.z,gy1.z,gz1.z);
vec3 g111=vec3(gx1.w,gy1.w,gz1.w);
vec4 norm0=taylorInvSqrt(vec4(dot(g000,g000),dot(g010,g010),dot(g100,g100),dot(g110,g110)));
g000*=norm0.x;
g010*=norm0.y;
g100*=norm0.z;
g110*=norm0.w;
vec4 norm1=taylorInvSqrt(vec4(dot(g001,g001),dot(g011,g011),dot(g101,g101),dot(g111,g111)));
g001*=norm1.x;
g011*=norm1.y;
g101*=norm1.z;
g111*=norm1.w;
float n000=dot(g000,Pf0);
float n100=dot(g100,vec3(Pf1.x,Pf0.yz));
float n010=dot(g010,vec3(Pf0.x,Pf1.y,Pf0.z));
float n110=dot(g110,vec3(Pf1.xy,Pf0.z));
float n001=dot(g001,vec3(Pf0.xy,Pf1.z));
float n101=dot(g101,vec3(Pf1.x,Pf0.y,Pf1.z));
float n011=dot(g011,vec3(Pf0.x,Pf1.yz));
float n111=dot(g111,Pf1);
vec3 fade_xyz=fade(Pf0);
vec4 n_z=mix(vec4(n000,n100,n010,n110),vec4(n001,n101,n011,n111),fade_xyz.z);
vec2 n_yz=mix(n_z.xy,n_z.zw,fade_xyz.y);
float n_xyz=mix(n_yz.x,n_yz.y,fade_xyz.x);
return 2.2*n_xyz;
}
vec3 distort(vec3 p){
float speed=.1;
float noise=cnoise(p)*.5;
p.x+=cos(iTime*speed+p.x*noise*100.)*.2;
p.y+=sin(iTime*speed+p.x*noise*100.)*.2;
p.z+=cos(iTime*speed+p.x*noise*100.)*.2;
return p;
}
void main(){
vec3 p = position;
p = distort(p);
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
gl_PointSize = 50. * uPixelRatio;
vec4 mvPosition = modelViewMatrix * vec4(p, 1.);
gl_PointSize *= (1. / -mvPosition.z);
vertexUv = uv;
}
`,
fragmentShader: /* glsl */ `
uniform float iTime;
uniform vec3 iResolution;
uniform vec4 iMouse;
varying vec2 vertexUv;
void main() {
vec2 uv = gl_PointCoord;
uv = (uv - .5) * 2.;
float d = length(uv);
float c = .05 / d;
c = pow(c, 2.);
gl_FragColor=vec4(vec3(1.), c);
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
uniforms: {
uPixelRatio: {
value: this.renderer.getPixelRatio(),
},
},
});
const points = new THREE.Points(geometry, material);
this.scene.add(points);
const uj = new kokomi.UniformInjector(this);
material.uniforms = {
...material.uniforms,
...uj.shadertoyUniforms,
};
this.update(() => {
uj.injectShadertoyUniforms(material.uniforms);
});
}
}
const sketch = new Sketch("#sketch");
sketch.create();
</script>
</body>
</html>
这样一来,我们的顶点着色器部分就暂告一段落了。
稍微总结一下吧:
- 利用
three.js
和kokomi.js
可以快速搭建一个3D
世界 - 网格
Mesh
由几何体和材质构成,而自定义材质可以用ShaderMaterial
来完成。 varying
变量限定符负责将变量从顶点着色器传给片元着色器- 利用
kokomi.js
的UniformInjector
能把Shadertoy
的uniform
变量注入自定义材质。 - 可以通过改变
position
顶点位置变量来实现顶点扭曲,除了用一般的数学计算来扭曲外,也可以用噪声函数进行扭曲。 - 粒子
Points
是three.js
另一种支持的物体;能通过gl_PointSize
来设定粒子的大小;通过改变材质的一些参数,能美化粒子效果的显示。 - 利用
three.js
的BufferGeometry
能自定义几何体的形状;通过创建Float32Array
类型数组,并将它填充到新的BufferAttribute
内,我们就能改变attribute
顶点信息变量的值。
多加摸索,从这里开始就变得复杂了,顶点着色器是Shader
的不可或缺的一环,跟片元着色器一样,二者缺一不可,一旦掌握到位了,我们就可以做出来很多令人叹为观止的3D
图形效果啦!