揭秘Phantom.land交互式网格与3D面部粒子系统的构建技术

本文深入解析Phantom.land网站如何利用React Three Fiber、GLSL着色器和GSAP构建动态交互网格与3D面部粒子系统,包含详细的代码实现和技术架构,展示了从2D图像到3D体积渲染的创新技术方案。

隐形力量:Phantom.land交互式网格与3D面部粒子系统的构建

从一开始,我们就希望打破传统机构网站的模式。受到驱动创造力、连接和变革的无形能量启发,我们提出了"隐形力量"的概念。我们能否将塑造世界的强大但无形的元素——运动、情感、直觉和灵感——在数字空间中具象化?

我们对于创建包含许多自定义交互和非常体验感的东西感到兴奋。然而,我们关心的是选择一套工具,让大多数开发人员能够在网站启动后贡献和维护。

我们选择从Next/React基础开始,就像我们在Phantom经常做的那样。React还有一个优势是与优秀的React Three Fiber库兼容,我们用它来无缝桥接DOM组件和整个网站使用的WebGL上下文之间的差距。对于样式,我们使用自己的CSS组件以及SASS。

对于交互行为和动画,我们选择使用GSAP,主要有两个原因。首先,它包含许多我们熟悉和喜爱的插件,如SplitText、CustomEase和ScrollTrigger。其次,GSAP允许我们在DOM和WebGL组件之间使用单一的动画框架。

我们可以无休止地谈论网站上每个动画和微交互背后的细节,但在这篇文章中,我们选择将注意力集中在两个最独特的组件上:主页网格和可滚动的员工面部粒子轮播。

主页网格

我们花了很长时间才让这个视图的性能和感觉达到我们想要的效果。在本文中,我们将重点介绍交互部分。有关如何提高性能的更多信息,请参阅我们之前的文章:欢迎回到Phantomland。

网格视图

项目的网格视图通过将原始的Three.js对象整合到React Three Fiber场景中,集成到主页中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// GridView.tsx
const GridView = () => {
  return (
    <Canvas>
      ...
      <ProjectsGrid />
      <Postprocessing />
    </Canvas>
  );
}

// ProjectsGrid.tsx
const ProjectsGrid = ({atlases, tiles}: Props) => {
  const {canvas, camera} = useThree();
  
  const grid = useMemo(() => {
    return new Grid(canvas, camera, atlases, tiles);
  }, [canvas, camera, atlases, tiles]);

  if(!grid) return null;
  return (
    <primitive object={grid} />
  );
}

我们最初想使用React Three Fiber编写网格的所有代码,但由于网格组件的复杂性,发现使用原生的Three.js类更容易维护。

后处理失真效果

赋予网格标志性感觉的关键元素之一是我们的后处理失真效果。我们通过在后处理管道中创建自定义着色器通道来实现此功能:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Postprocessing.tsx
const Postprocessing = () => {
  const {gl, scene, camera} = useThree();
  
  // 创建效果合成器
  const {effectComposer, distortionShader} = useMemo(() => {
    const renderPass = new RenderPass(scene, camera);
    const distortionShader = new DistortionShader();
    const distortionPass = new ShaderPass(distortionShader);
    const outputPass = new OutputPass();

    const effectComposer = new EffectComposer(gl);
    effectComposer.addPass(renderPass);
    effectComposer.addPass(distortionPass);
    effectComposer.addPass(outputPass);

    return {effectComposer, distortionShader};
  }, []);
  
  // 更新失真强度
  useEffect(() => {
    if (workgridState === WorkgridState.INTRO) {
      distortionShader.setDistortion(CONFIG.distortion.flat);
    } else {
      distortionShader.setDistortion(CONFIG.distortion.curved);
    }
  }, [workgridState, distortionShader]);
  
  // 更新失真强度
  useFrame(() => {
    effectComposer.render();
  }, 1);
 
  return null;
}

当网格在网站上过渡进出时,失真强度会发生变化,使过渡感觉自然。这个动画是通过我们的DistortionShader类中的一个简单补间完成的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class DistortionShader extends ShaderMaterial {
  private distortionIntensity = 0;

  super({
      name: 'DistortionShader',
      uniforms: {
        distortionIntensity: {value: new Vector2()},
        ...
      },
      vertexShader,
      fragmentShader,
  });

  update() {
    const ratio = window.innerWidth, window.innerHeight;
    this.uniforms[DistortionShaderUniforms.DISTORTION].value.set(
      this.distortionIntensity * ratio,
      this.distortionIntensity * ratio,
    );
  }

  setDistortion(value: number) {
    gsap.to(this, {
      distortionIntensity: value,
      duration: 1,
      ease: 'power2.out',
      onUpdate: () => this.update()    }
  }
}

然后通过我们的自定义着色器应用失真:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// fragment.ts
export const fragmentShader = /* glsl */ `
  uniform sampler2D tDiffuse;
  uniform vec2 distortion;
  uniform float vignetteOffset;
  uniform float vignetteDarkness;

  varying vec2 vUv;
  
  // 将uv范围从0->1转换为-1->1
  vec2 getShiftedUv(vec2 uv) {
    return 2. * (uv - .5);
  }
  
  // 将uv范围从-1->1转换为0->1
  vec2 getUnshiftedUv(vec2 shiftedUv) {
    return shiftedUv * 0.5 + 0.5;
  }

  void main() {
    vec2 shiftedUv = getShiftedUv(vUv);
    float distanceToCenter = length(shiftedUv);
    
    // 镜头失真效果
    shiftedUv *= (0.88 + distortion * dot(shiftedUv));
    vec2 transformedUv = getUnshiftedUv(shiftedUv);
    
    // 晕影效果
    float vignetteIntensity = smoothstep(0.8, vignetteOffset * 0.799,  (vignetteDarkness + vignetteOffset) * distanceToCenter);
    
    // 采样渲染纹理并输出片段
    color = texture2D( tDiffuse, distortedUV ).rgb * vignetteIntensity;
    gl_FragColor = vec4(color, 1.);
  }

我们还在后处理着色器中添加了晕影效果,使视口角落变暗,将用户的注意力集中在屏幕中心。

为了使我们的主页视图尽可能流畅,我们还花了相当多的时间来制作网格的微交互和过渡。

环境鼠标偏移

当用户在网格周围移动光标时,网格会略微向相反方向移动,产生非常微妙的环境浮动效果。这通过计算鼠标在网格上的位置并相应移动网格网格来实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
getAmbientCursorOffset() {
  // 获取UV空间中的指针坐标(0-1)范围
  const uv = this.navigation.pointerUv;
  const offset = uv.subScalar(0.5).multiplyScalar(0.2);
  return offset;
}

update() {
  ...
  // 将光标偏移应用到网格位置
  const cursorOffset = getAmbientCursorOffset();
  this.mesh.position.x += cursorOffset.x;
  this.mesh.position.y += cursorOffset.y;
}

拖拽缩放

当网格被拖拽时,会发生缩放效果,相机似乎从网格平移离开。我们通过检测用户开始和停止拖拽光标来创建此效果,然后使用它触发带有自定义缓动的GSAP动画以获得额外控制。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
onPressStart = () => {
  this.animateCameraZ(0.5, 1);
}

onPressEnd = (isDrag: boolean) => {
  if(isDrag) {
    this.animateCameraZ(0, 1);
  }
}

animateCameraZ(distance: number, duration: number) {
  gsap.to(this.camera.position, {
    z: distance,
    duration,
    ease: CustomEase.create('cameraZoom', '.23,1,0.32,1'),
  });
}

拖拽移动

最后但同样重要的是,当用户拖拽网格并释放光标时,网格会以一定的惯性滑动。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
drag(offset: Vector2) {
  this.dragAction = offset;

  // 随着拖拽时间和距离逐渐增加速度
  this.velocity.lerp(offset, 0.8);
}

// 每帧
update() {
  // positionOffset稍后用于移动网格网格
  if(this.isDragAction) {
    // 如果用户正在拖拽光标,将拖拽值添加到偏移量
    this.positionOffset.add(this.dragAction.clone());
  } else {
    // 如果用户没有拖拽,将速度添加到偏移量
    this.positionOffset.add(this.velocity);
  }

  this.dragAction.set(0, 0);
  // 随时间衰减速度
  this.velocity.lerp(new Vector2(), 0.1);
}

面部粒子

我们想要强调的第二个主要组件是我们的员工面部轮播,它通过动态的3D粒子系统展示团队成员。使用React Three Fiber的BufferGeometry和自定义GLSL着色器构建,此实现利用自定义着色器材料实现轻量级性能和灵活性,使我们能够仅使用2D彩色照片及其相应的深度图生成完整的3D面部表示——无需3D模型。

核心概念:深度驱动的粒子生成

我们面部粒子系统的基础在于将2D图像转换为体积3D表示。我们保持了效率,每个面部仅使用两个优化的256×256 WebP图像(每个小于15KB)。

为了捕获图像,Phantom团队的每个成员都使用Unreal Engine的RealityScan在iPhone上进行3D扫描,创建了他们面部的3D模型。

这些扫描被清理,然后从Cinema4D渲染出位置和颜色通道。

位置通道在Photoshop中转换为灰度深度图,这个——连同颜色通道——在需要的地方进行修饰、裁剪,然后从Photoshop导出与开发团队共享。

每个面部由大约78,400个粒子(280×280网格)构建,每个粒子的位置和外观通过从我们的两个源纹理采样数据来确定。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* 生成位置属性数组 */
const POINT_AMOUNT = 280;

const points = useMemo(() => {
  const length = POINT_AMOUNT * POINT_AMOUNT;
  const vPositions = new Float32Array(length * 3);
  const vIndex = new Float32Array(length * 2);
  const vRandom = new Float32Array(length * 4);

  for (let i = 0; i < length; i++) {
      const i2 = i * 2;
      vIndex[i2] = (i % POINT_AMOUNT) / POINT_AMOUNT;
      vIndex[i2 + 1] = i / POINT_AMOUNT / POINT_AMOUNT;

      const i3 = i * 3;
      const theta = Math.random() * 360;
      const phi = Math.random() * 360;
      vPositions[i3] = 1 * Math.sin(theta) * Math.cos(phi);
      vPositions[i3 + 1] = 1 * Math.sin(theta) * Math.sin(phi);
      vPositions[i3 + 2] = 1 * Math.cos(theta);

      const i4 = i * 4;
      vRandom.set(
        Array(4)
          .fill(0)
          .map(() => Math.random()),
        i4,
      );
  }

  return {vPositions, vRandom, vIndex};
}, []);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// React Three Fiber组件结构
const FaceParticleSystem = ({ particlesData, currentDataIndex }) => {
  return (
    <points ref={pointsRef} position={pointsPosition}>
      <bufferGeometry>
        <bufferAttribute attach="attributes-vIndex" 
             args={[points.vIndex, 2]} />
        <bufferAttribute attach="attributes-position"
             args={[points.vPositions, 3]} />
        <bufferAttribute attach="attributes-vRandom"
             args={[points.vRandom, 4]} />
      </bufferGeometry>
      
      <shaderMaterial
        blending={NormalBlending}
        transparent={true}
        fragmentShader={faceFrag}
        vertexShader={faceVert}
        uniforms={uniforms}
      />
    </points>
  );
};

深度图提供标准化值(0-1),直接转换为Z深度定位。值0代表最远的点(背景),而1代表最近的点(通常是鼻尖)。

1
2
3
4
5
6
7
8
/* 顶点着色器 */ 

// 为每个粒子采样深度和颜色数据
vec3 depthTexture1 = texture2D(depthMap1, vIndex.xy).xyz;

// 将深度转换为Z位置
float zDepth = (1. - depthValue.z);
pos.z = (zDepth * 2.0 - 1.0) * zScale;

通过颜色分析的动态粒子缩放

使我们的面部栩栩如生的关键方法之一是利用颜色数据来影响粒子缩放。在我们的顶点着色器中,而不是使用统一的粒子大小,我们分析每个像素的颜色密度,以便面部的更亮、更多彩的区域(如眼睛、嘴唇或光线充足的脸颊)生成更大、更突出的粒子,而较暗的区域(阴影、头发)创建更小、更微妙的粒子。结果是更有机、逼真的表示,自然地强调面部特征。

1
2
3
4
5
6
7
8
9
/* 顶点着色器 */ 

vec3 colorTexture1 = texture2D(colorMap1, vIndex.xy).xyz;

// 计算颜色密度
float density = (mainColorTexture.x + mainColorTexture.y + mainColorTexture.z) / 3.;

// 将密度映射到粒子缩放
float pScale = mix(pScaleMin, pScaleMax, density);

下面的校准展示了颜色(对比度、亮度等)对最终3D粒子形成的影响。

环境噪声动画

为了防止静态外观并保持视觉兴趣,我们对所有粒子应用连续的基于噪声的动画。这个环境动画系统使用卷曲噪声在整个面部结构上创建微妙的流动运动。

1
2
3
4
/* 顶点着色器 */ 

// 用于整体运动的主要卷曲噪声
pos += curlNoise(pos * curlFreq1 + time) * noiseScale * 0.1;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// React Three Fiber中的动画更新

useFrame((state, delta) => {
  if (!materialRef.current) return;
  
  materialRef.current.uniforms.time.value = state.clock.elapsedTime * NOISE_SPEED;
  
  // 基于鼠标交互更新旋转
  easing.damp(pointsRef.current.rotation, 'y', state.mouse.x * 0.12 * Math.PI, 0.25, delta);
  easing.damp(pointsRef.current.rotation, 'x', -state.pointer.y * 0.05 * Math.PI, 0.25, delta);

});

面部过渡动画

在不同团队成员之间过渡时,我们结合了基于时间轴的插值和在着色器材料中编写的视觉效果。

GSAP驱动的Lerp方法

过渡基础使用GSAP时间轴同时动画多个着色器参数:

1
2
3
4
5
timelineRef.current = gsap
  .timeline()
  .fromTo(uniforms.transition, {value: 0}, {value: 1.3, duration: 1.6})
  .to(uniforms.posZ, {value: particlesParams.offset_z, duration: 1.6}, 0)
  .to(uniforms.zScale, {value: particlesParams.face_scale_z, duration: 1.6}, 0);

着色器处理两个面部状态之间的视觉混合:

1
2
3
4
5
6
7
8
9
/* 顶点着色器 */ 

// 平滑过渡曲线
float speed = clamp(transition * mix(0.8, .9, transition), 0., 1.0); 
speed = smoothstep(0.0, 1.0, speed); 

// 混合纹理
vec3 mainColorTexture = mix(colorTexture1, colorTexture2, speed); 
vec3 depthValue =mix(depthTexture1, depthTexture2, speed);

为了在过渡期间增加视觉兴趣,我们进一步注入额外的噪声,在过渡中点最强。这创建了一个微妙的"干扰"效果,粒子暂时偏离其目标位置,使过渡感觉更动态和有机。

1
2
3
4
5
6
7
8
9
/* 顶点着色器 */ 

// 应用于过渡的次级噪声运动
float randomZ = vRandom.y + cnoise(pos * curlFreq2 + t2) * noiseScale2;

float smoothTransition = abs(sin(speed * PI)); 
pos.x += nxScale * randomZ * 0.1 * smoothTransition; 
pos.y += nyScale *randomZ * 0.1 * smoothTransition;
pos.z += nzScale * randomZ * 0.1 * smoothTransition;

自定义景深效果

为了增强三维感知,我们在着色器材料中直接实现了自定义景深效果。它计算每个粒子的视图空间距离,并根据与可配置焦平面的接近程度调制不透明度和大小。

1
2
3
4
5
6
7
/* 顶点着色器 - 计算视图距离 */

vec4 viewPosition = viewMatrix * modelPosition;
vDistance = abs(focus +viewPosition.z); 

// 将距离应用到点大小以实现模糊效果
gl_PointSize = pointSize * pScale * vDistance * blur * totalScale;
1
2
3
4
/* 片段着色器 - 计算基于距离的alpha用于景深 */

float alpha = (1.04 - clamp(vDistance * 1.5, 0.0, 1.0));
gl_FragColor = vec4(color, alpha);

挑战:统一面部比例

我们面临的挑战之一是在不同团队成员的照片之间实现视觉一致性。每张照片都是在稍微不同的条件下拍摄的——不同的光照、相机距离和面部比例。因此,我们检查了每个面部以校准多个缩放因子:

  • 深度比例校准,确保没有鼻子过于突出
  • 颜色密度平衡,保持一致的粒子大小关系
  • 焦平面优化,防止任何单个面部过度模糊
1
2
3
4
5
6
// 需要手动调整的个体面部参数

particle_params: { 
  offset_z: 0,           // 整体Z位置
  z_depth_scale: 0,      // 深度图缩放因子
  face_size: 
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计