【React-Three-Fiber实践】放弃Shader!用顶点颜色实现高性能3D可视化

在现代前端开发中,3D可视化已经成为提升用户体验的重要手段。然而,许多开发者在实现复杂视觉效果时,往往会首先想到使用Shader(着色器)。虽然Shader功能强大,但学习曲线陡峭,实现复杂度高。本文将介绍一种更简单高效的替代方案------顶点颜色(Vertex Colors)技术,通过一个彩色二十面体的实现案例,展示如何在不使用Shader的情况下创建令人惊艳的3D效果。
下图里每个几何体有顶点着色的几何和显示框线的结合重叠而形成的视觉效果,从左到右分别列举了三个插值方案

  1. 方案1:基于Y轴高度的HSL全色相变化,从底部到顶部呈现彩虹色渐变(图1)

  2. 方案2:固定红色相,饱和度随高度变化,从灰到纯红(图3)

  3. 方案3:RGB渐变,从底部的黄色渐变到顶部的红色(图2)

本文参考了threejs官网给的下面例子

为什么选择顶点颜色而非Shader?

Shader无疑是强大的工具,可以实现几乎任何你能想象到的视觉效果。但对于许多常见的3D可视化需求来说,Shader可能有些"杀鸡用牛刀":

  1. 学习成本高:GLSL语言和着色器管线对初学者不友好

  2. 调试困难:着色器错误往往难以定位和修复

  3. 性能考量:简单的顶点颜色渲染通常比复杂着色器更高效

  4. 开发效率:使用Three.js内置材质可以快速迭代

在我们的案例中,使用顶点颜色技术完全能够满足需求,同时保持了代码的简洁性和可维护性。

实现彩色二十面体完整代码

让我们通过一个React Three Fiber实现的彩色二十面体组件,来具体看看顶点颜色技术的应用。

javascript 复制代码
import { useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'

type IcosahedronProps = {
  position: [number, number, number]
  colorScheme: 1 | 2 | 3
}

const Icosahedron = ({ position, colorScheme }: IcosahedronProps) => {
  const meshRef = useRef<THREE.Group>(null)
  const radius = 200

  useFrame(() => {
    if (meshRef.current) {
      meshRef.current.rotation.y += 0.005
    }
  })

  // 创建几何体和颜色
  const geometry = new THREE.IcosahedronGeometry(radius, 1)
  const count = geometry.attributes.position.count
  geometry.setAttribute(
    'color',
    new THREE.BufferAttribute(new Float32Array(count * 3), 3),
  )

  const positions = geometry.attributes.position as THREE.BufferAttribute
  const colors = geometry.attributes.color as THREE.BufferAttribute
  const color = new THREE.Color()

  for (let i = 0; i < count; i++) {
    const y = positions.getY(i)
    switch (colorScheme) {
      case 1: // 基于Y轴高度的HSL颜色
        color.setHSL((y / radius + 1) / 2, 1.0, 0.5, THREE.SRGBColorSpace)
        break
      case 2: // 基于Y轴高度的HSL颜色,固定色相
        color.setHSL(0, (y / radius + 1) / 2, 0.5, THREE.SRGBColorSpace)
        break
      case 3: // 基于Y轴高度的RGB渐变
        color.setRGB(1, 0.8 - (y / radius + 1) / 2, 0, THREE.SRGBColorSpace)
        break
    }
    colors.setXYZ(i, color.r, color.g, color.b)
  }

  return (
    <group ref={meshRef} position={position}>
      <mesh geometry={geometry}>
        <meshPhongMaterial
          color={0xffffff}
          flatShading
          vertexColors
          shininess={0}
        />
      </mesh>
      <mesh geometry={geometry}>
        <meshBasicMaterial
          color={0x000000}
          wireframe
          transparent
          opacity={0.3}
        />
      </mesh>
    </group>
  )
}

export const ColorMap = () => {
  return (
    <>
      <Icosahedron position={[-400, 0, 0]} colorScheme={1} />
      <Icosahedron position={[0, 0, 0]} colorScheme={3} />
      <Icosahedron position={[400, 0, 0]} colorScheme={2} />
    </>
  )
}

我们定义了一个Icosahedron组件,接受位置和颜色方案作为props。使用useRef来获取对3D对象的引用,以便后续动画控制。

Icosahedron是什么

Icosahedron(发音:/ˌaɪkɒsəˈhiːdrən/ 或 /ˌaɪkoʊsəˈhiːdrən/)是一个几何术语,源自希腊语:

  • eíkosi (εἴκοσι) = "20"

  • hédra (ἕδρα) = "面"或"基面"

正二十面体 ------一种由20个完全相同的正三角形面、30条边和12个顶点组成的柏拉图立体 (Platonic solid)。每个顶点处有5个三角形面相交。对称性 :具有120°旋转对称性;结构 :所有面、边、角均全等;可视化:类似足球的经典结构(现代足球的拼合结构即源自截角二十面体)

几何体创建与颜色设置代码解析

核心部分在于如何为二十面体的每个顶点设置颜色:

javascript 复制代码
const positions = geometry.attributes.position as THREE.BufferAttribute
const colors = geometry.attributes.color as THREE.BufferAttribute
const color = new THREE.Color()

for (let i = 0; i < count; i++) {
  const y = positions.getY(i)
  switch (colorScheme) {
    case 1: // 基于Y轴高度的HSL颜色
      color.setHSL((y / radius + 1) / 2, 1.0, 0.5, THREE.SRGBColorSpace)
      break
    case 2: // 基于Y轴高度的HSL颜色,固定色相
      color.setHSL(0, (y / radius + 1) / 2, 0.5, THREE.SRGBColorSpace)
      break
    case 3: // 基于Y轴高度的RGB渐变
      color.setRGB(1, 0.8 - (y / radius + 1) / 2, 0, THREE.SRGBColorSpace)
      break
  }
  colors.setXYZ(i, color.r, color.g, color.b)
}

这段代码做了以下几件事:

  1. 通过new THREE.IcosahedronGeometry(radius, 1)创建一个二十面体几何体

  2. 为几何体添加颜色属性

  3. 遍历所有顶点,根据Y轴坐标和选定的颜色方案为每个顶点设置颜色

  4. 三种颜色方案分别展示了不同的着色策略

1. 基本结构

遍历几何体所有顶点的循环,对每个顶点:

  • 获取顶点的Y坐标(positions.getY(i))

  • 根据colorScheme选择不同的颜色计算方式

  • 将计算好的颜色值设置到顶点颜色属性中(colors.setXYZ)

2. 核心变量

  • count: 几何体的顶点总数

  • positions: 包含所有顶点位置数据的BufferAttribute

  • colors: 用于存储顶点颜色数据的BufferAttribute

  • radius: 几何体的半径(用于标准化Y坐标)

  • color: THREE.Color对象,用于临时存储计算的颜色值

3. 颜色方案解析

方案1 (case 1): 基于Y轴高度的HSL颜色
javascript 复制代码
        color.setHSL((y / radius + 1) / 2, 1.0, 0.5, THREE.SRGBColorSpace)

color.setHSL((y / radius + 1) / 2, 1.0, 0.5, THREE.SRGBColorSpace) 能实现彩虹渐变效果,核心在于 HSL色彩模型的特性和Y坐标的映射关系。以下是逐层解析:


  1. HSL色彩模型基础

HSL代表:

  • H (Hue):色相(0~1循环,红→黄→绿→青→蓝→紫→红)

  • S (Saturation):饱和度(0=灰色,1=纯色)

  • L (Lightness):亮度(0=黑,0.5=纯色,1=白)

代码中固定了:

  • 饱和度 S=1.0(最鲜艳)

  • 亮度 L=0.5(不偏白不偏黑)


  1. 关键公式:(y / radius + 1) / 2

分步解析:

  1. y / radius

    • 将顶点Y坐标归一化到 [-1, 1] 范围(假设几何体中心在原点)

    • 底部y ≈ -radius → 值接近 -1

    • 顶部y ≈ radius → 值接近 1

  2. + 1

    • 将范围平移为 [0, 2]

    • 底部-1 + 1 = 0

    • 顶部1 + 1 = 2

  3. / 2

    • 最终映射到 [0, 1] 的标准HSL色相范围

    • 底部0 / 2 = 0(红色)

    • 中部1 / 2 = 0.5(青色)

    • 顶部2 / 2 = 1(循环回红色)


  1. 彩虹渐变的形成

通过Y坐标与色相H的线性映射:

  • 底部 (y=-radius) → H=0 → 红色

  • 中部偏下 (y≈-0.5radius) → H≈0.25 → 黄色

  • 中部 (y=0) → H=0.5 → 青色

  • 中部偏上 (y≈0.5radius) → H≈0.75 → 蓝色

  • 顶部 (y=radius) → H=1 → 红色(循环)

由于色相H在HSL模型中是一个环形光谱(红→黄→绿→青→蓝→紫→红),这种映射自然形成了连续的彩虹色渐变。


  1. 为什么不是从紫色到红色?

虽然H=1理论上会回到红色,但在实际渲染中:

  • 顶部顶点通常不会恰好达到H=1(因浮点精度或几何体细分程度)

  • 人眼对蓝-紫色的变化更敏感,视觉上会感觉渐变完整

方案2 (case 2): 基于Y轴高度的HSL颜色,固定色相

javascript 复制代码
        color.setHSL(0, (y / radius + 1) / 2, 0.5, THREE.SRGBColorSpace)

固定色相H=0(红),仅调整饱和度 → 红-灰渐变

方案3 (case 3): 基于Y轴高度的RGB渐变

javascript 复制代码
        color.setRGB(1, 0.8 - (y / radius + 1) / 2, 0, THREE.SRGBColorSpace)

color.setRGB(1, 0.8 - (y / radius + 1) / 2, 0, THREE.SRGBColorSpace) 能实现 从黄色到红色的渐变 ,核心在于 RGB通道的数学关系和Y坐标的映射。以下是逐层解析:


  1. RGB颜色模型基础
  • R (Red):红色分量(0~1)

  • G (Green):绿色分量(0~1)

  • B (Blue):蓝色分量(0~1)

组合效果:

  • (1, 1, 0) = 黄色(红+绿)

  • (1, 0, 0) = 纯红色

  • (1, 0.5, 0) = 橙红色


  1. 关键公式解析:0.8 - (y / radius + 1) / 2

分步计算绿色分量(G):

  1. y / radius

    • 将Y坐标归一化到 [-1, 1](几何体中心在原点时)

    • 底部y ≈ -radius → 值接近 -1

    • 顶部y ≈ radius → 值接近 1

  2. + 1

    • 平移范围到 [0, 2]

    • 底部-1 + 1 = 0

    • 顶部1 + 1 = 2

  3. / 2

    • 压缩到 [0, 1]

    • 底部0 / 2 = 0

    • 顶部2 / 2 = 1

  4. 0.8 - ...

    • 反转并偏移计算结果

    • 底部0.8 - 0 = 0.8

    • 顶部0.8 - 1 = -0.2(实际会被限制为0)


  1. 颜色渐变过程
Y坐标位置 绿色分量 (G) 计算 RGB值 颜色表现
底部 (y=-radius) 0.8 - (0)/2 = 0.8 (1, 0.8, 0) 亮黄色
中部偏下 0.8 - (0.5)/2 = 0.55 (1, 0.55, 0) 橙黄色
中部 (y=0) 0.8 - (1)/2 = 0.3 (1, 0.3, 0) 橙红色
顶部 (y=radius) 0.8 - (2)/2 = -0.2 → 0 (1, 0, 0) 纯红色

  1. 为什么是黄→红?

  2. 底部黄色

    • R=1(最大红) + G=0.8(高绿) + B=0 → 接近纯黄
  3. 过渡阶段

    • 绿色分量从0.8线性减少 → 颜色逐渐偏向红色
  4. 顶部红色

    • G被限制为0 → 仅剩R=1 → 纯红

  1. 设计巧思
  • 固定R=1:保持红色主导,避免颜色跳跃

  • G的递减公式:通过Y坐标控制绿色衰减速度

  • B=0:完全禁用蓝色通道,确保暖色渐变

  • 0.8的偏移量 :避免底部颜色过暗(若用1 - (y/radius+1)/2,底部G=1,顶部G=0,效果类似)


对比其他方案

  • HSL方案:通过色相H变化实现彩虹渐变

  • 此RGB方案 :通过固定R、衰减G,实现暖色系线性过渡,更适合需要单一色调渐变的场景(如温度可视化、危险等级提示等)。

这种设计以极简的数学映射,实现了符合直觉的颜色过渡效果。

4. 数学关系可视化

对于Y坐标从-bottom到top的变化:

方案 底部颜色(y=-radius) 顶部颜色(y=radius) 渐变方向
1 色相=0(红) 色相=1(回到红) 彩虹色
2 饱和度=0(灰) 饱和度=1(纯红) 红渐变
3 RGB(1,0.8,0)黄 RGB(1,0,0)红 黄到红

5. 实际应用

这种技术常用于:

  • 可视化高度数据

  • 创建彩色3D地形

  • 调试3D模型(查看顶点分布)

  • 艺术化渲染效果

渲染与动画

javascript 复制代码
return (
  <group ref={meshRef} position={position}>
    <mesh geometry={geometry}>
      <meshPhongMaterial
        color={0xffffff}
        flatShading
        vertexColors
        shininess={0}
      />
    </mesh>
    <mesh geometry={geometry}>
      <meshBasicMaterial
        color={0x000000}
        wireframe
        transparent
        opacity={0.3}
      />
    </mesh>
  </group>
)

我们使用两个网格叠加的方式实现最终效果:

  • 第一个使用meshPhongMaterial并启用vertexColors,显示彩色表面

  • 第二个使用meshBasicMaterial的线框模式,添加轮廓增强立体感

无边框的效果

有边框的效果

动画通过useFrame钩子实现简单旋转:

javascript 复制代码
useFrame(() => {
  if (meshRef.current) {
    meshRef.current.rotation.y += 0.005
  }
})

改变颜色插值取值

上面我们使用了模型的坐标Y值进行了从下到上的颜色插值。本质上是根据顶点的某个信息的值在一个区间内映射得到的值进行颜色插值。

根据坐标的X值在[-radius,radius]进行插值

javascript 复制代码
  for (let i = 0; i < count; i++) {
    const y = positions.getX(i)
    switch (colorScheme) {
      case 1: // 基于Y轴高度的HSL颜色
        color.setHSL((y / radius + 1) / 2, 1.0, 0.5, THREE.SRGBColorSpace)
        break
      case 2: // 基于Y轴高度的HSL颜色,固定色相
        color.setHSL(0, (y / radius + 1) / 2, 0.5, THREE.SRGBColorSpace)
        break
      case 3: // 基于Y轴高度的RGB渐变
        color.setRGB(1, 0.8 - (y / radius + 1) / 2, 0, THREE.SRGBColorSpace)
        break
    }
    colors.setXYZ(i, color.r, color.g, color.b)
  }

根据坐标的z值进行插值

javascript 复制代码
  for (let i = 0; i < count; i++) {
    const y = positions.getZ(i)
    switch (colorScheme) {
      case 1: // 基于Y轴高度的HSL颜色
        color.setHSL((y / radius + 1) / 2, 1.0, 0.5, THREE.SRGBColorSpace)
        break
      case 2: // 基于Y轴高度的HSL颜色,固定色相
        color.setHSL(0, (y / radius + 1) / 2, 0.5, THREE.SRGBColorSpace)
        break
      case 3: // 基于Y轴高度的RGB渐变
        color.setRGB(1, 0.8 - (y / radius + 1) / 2, 0, THREE.SRGBColorSpace)
        break
    }
    colors.setXYZ(i, color.r, color.g, color.b)
  }

由于threejs是默认z轴是从里到外的,可以看到屏幕最近的颜色是颜色插值的最大值

如何确定某个顶点的颜色?

首先确定要映射的颜色区间。然后要考虑用顶点的什么属性值参与映射,比如像CAE仿真中后处理结果中模拟温度的颜色,就取顶点对应的温度值在温度的上下限中的[0,1]标准化区间的值,在结合上面所的比如彩虹映射得到该顶点的颜色值

性能优势

顶点颜色技术的主要性能优势在于:

  1. 减少绘制调用:所有颜色信息已经包含在顶点数据中,无需额外纹理或uniform

  2. GPU友好:颜色计算在初始化时完成,渲染时直接使用预计算数据

  3. 内存高效:相比纹理贴图,顶点颜色占用内存更少

对于需要渲染大量相似对象的场景(如科学可视化、地图标记等),这种技术可以显著提升性能。

适用场景

顶点颜色技术特别适合以下场景:

  • 数据可视化(热力图、高度图等)

  • 简单的颜色渐变效果

  • 需要高性能的移动端3D应用

  • 快速原型开发,避免复杂着色器编写

总结

通过这个彩色二十面体的实现,我们展示了顶点颜色技术在3D可视化中的强大能力。相比Shader方案,这种方法:

  • 更易于理解和实现

  • 调试和维护更简单

  • 性能表现优异

  • 足够满足许多常见可视化需求

当你的项目不需要Shader提供的极端灵活性时,考虑使用顶点颜色技术可能会带来更好的开发体验和性能表现。Three.js和React Three Fiber提供的丰富API使得这种实现方式既简单又强大,是前端开发者进入3D可视化世界的理想起点。
下次当你面临3D可视化需求时,不妨先问问自己:我真的需要Shader吗?也许顶点颜色就能完美解决问题!