Switch branches/tags
Nothing to show
Find file History
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
..
Failed to load latest commit information.
assets
component
controller
asyncModule.ts
cover.jpg
index.ts
readme.md
types.ts

readme.md

Particle3DByThree

Some particles.

Code: https://github.com/dtysky/paradise/tree/master/src/collection/Particle3DByThree

Demo: http://paradise.dtysky.moe/effect/particle-3d-by-three

原理

这个效果是3D Particle Explorations其中之一的实现。觉得蛮有意思满酷炫就研究实现了一下。

基元

首先,顾名思义,这是一个粒子效果。和大多的粒子效果一样,它都是由一个一个小的“基元”组合而成的,而在本作品中这个基元就是“球体”,也就是说,那一个个粒子本质上是由SphereGeometry加上不同的Material形成的Mesh。对于本例的粒子,主要被设置的显示属性是半径、位置、颜色和透明度。

生长

光有粒子本身是无法实现效果的,我们还需要让它们按照一定的规律动起来。这个动起来包括两部分——其一是粒子自身的约束,其二是粒子间的约束。

首先是粒子自身的约束,这其实就是一个生命周期中,生长和消亡的过程。看到这个效果,我们很自然的可以想到——是不是有一个摄像机在对着粒子群,而粒子是在随着时间向着摄像头运动呢?当然是的,粒子群确实是在进行着“出生 -> 向摄像头运动 -> 消失”的生命循环,但实际上你会发现,仅仅是让粒子改变位置是无法达到这种效果的,我们还必须在粒子的一次生命周期中改变它的大小和透明度,使得变化更加自然:

this.mesh.scale.set(scale, scale, scale);
this.mesh.position.setZ(z);
// 4为设定的边界,可以调整
(this.mesh.material as THREE.Material).opacity = (1 - Math.abs(z) / 4) * this.config.opacity;

但仅仅这样还是不够的,如果所有粒子的行为都一致,那么实际的效果也只是粒子群同时运动到了某一平面,并没有实际效果的那种层次感。所以必须有一种细致操控每一个粒子的方法,来使得它们的生长具有差异化。这个方法也很简单,就是——给每个粒子设置不同的出生时间born和生命life,然后通过这两个参数控制粒子运动的整个周期,这就可以是的粒子群在宏观上拥有一致的运动曲线和终点,而在微观上每一个粒子又都各有自己的特点:

if (!this.initialized) {
  if (this.current < born) {
    this.current += deltaTime;
    return;
  }

  this.initialized = true;
  this.current = 0;
}

if (this.current + deltaTime > life) {
  return;
}

this.current += deltaTime;

这段代码说明了bornlife的作用,它们决定了每个粒子开始运动和结束运动的时间。

运动曲线

光有时间无法让粒子运动起来,我们还需要根据时间去算粒子的zscale,这就需要插值。一个简单的方式是线性插值,即值和时间成正比,但这显然无法达到我们的效果,我们需要的是一个Q弹的运动曲线,让每个粒子的运动有一种平滑自然的效果。

一般而言,为了实现曲线,大家都会选择Tween或者近似品,但为了从全局严格管控每一个粒子的行为,我引入了更底层的d3-eased3-interpolate,前者是运动曲线生成库,后者是插值库,二者配合就可以算出粒子起始和终点属性中任意的平滑的值。

现在让我们回到曲线本身,看着实例,想想我们究竟需要怎样的曲线?看起来像是QuadInOut对不对?不错,大概没错,但其实还是有些不同。QuadInOut的曲线是这样的:

                           -
                          -
                        --
                      --
                   ---
                ---   
            ----
         ---
      ---
    --  
  --       
 -      
-        

但我们需要的曲线是这样的:

                                          -
                                          -
                                        --
                                      --
                                  ---
                                ---   
            -------------------
         ---
      ---
    --  
  --       
 -      
-        

事实上,QuadInOut也不过是QuadInQuadOut的合成,它把两者各scale了0.5,之后再将二者拼起来,我们当然也可以这样做:

export function customInOut (duration: number, point1: number, point2: number, current: number): number {
  if (current < point1) {
    return d3Ease.easeQuadIn(current / point1) / 2;
  } else if (current < point2) {
    return .5;
  }

  return d3Ease.easeQuadOut((current - point2) / (duration - point2)) / 2 + .5;
}

这里我将曲线分成了三个阶段,第一阶段用QuadIn,第二阶段保持状态,第三阶段用QuadOut,这就是我们需要的曲线。有了曲线,插值就十分简单了,根据阶段选择不同的插值器即可,这里不再赘述:

const percent = customInOut(this.config.life, life * edgeTime1, life * edgeTime2, this.current);

if (percent === .5) {
  return;
}

const index = percent < .5 ? 0 : 1;
const realPercent = percent < .5 ? percent * 2 : percent * 2 - 1;
const {scale, z} = this.interpolates[index](realPercent);

斥力

到了这一步,粒子可以运动起来,也可以有层次感,但是不是觉得还少了些什么?对,还少了散开的效果。从实例中不难发现,粒子间的间距实际上是动态变化,当一个新的粒子插入粒子群时,所有的粒子开始动态调整间隙,一开始逐渐变大,到了一定的间隙之后基本保持不变。

这个现象很自然得可以想到用斥力来描述,废话不多说放代码:

for (let i = 0; i < count; i += 1) {
  const p1 = this.particles[i];
  const p1Pos = p1.mesh.position;

  if (!p1.initialized) {
    continue;
  }

  for (let j = 0; j < count; j += 1) {
    const p2 = this.particles[j];

    if (p1 === p2 || !p2.initialized) {
      continue;
    }

    const p2Pos = p2.mesh.position;
    const dx = p1Pos.x - p2Pos.x;
    const dy = p1Pos.y - p2Pos.y;
    const dist = dx * dx + dy * dy;
    const radii = (p1.size + p2.size) * (p1.size + p2.size) + spacing;

    if (dist < radii) {
      const angle = Math.atan2(dy, dx) + generateRandom(-0.05, 0.05);
      const diff = radii - dist;
      const x = Math.cos(angle) * diff * 0.05;
      const y = Math.sin(angle) * diff * 0.05;
      p1Pos.x += x * dTime;
      p1Pos.y += y * dTime;
      p2Pos.x -= x * dTime;
      p2Pos.y -= y * dTime;
    }
  }
}

这段代码简单明(cu)了(bao),就是遍历每一个粒子,然后去计算其他粒子对该粒子的力的作用,这里要注意两个约束:只有出生了的粒子才会进行计算,只有在两个粒子的间隙小于约束的间隙时才会进行调整,这样可以避免无效和错误的计算。

至此,整个系统就可以完美运行了。