Three.js 学习系列(三)Geometry 与 Material 全面拆解

Mesh = Geometry + Material 这个等式我们已经会背了。但 Geometry 在内存里到底是什么样子?Material 为什么有 BasicMaterial / Standard / Phong / Physical / Shader 这么多种?这一篇把"顶点数据 + 着色策略"两条线一次讲透。
threejswebglgeometrymaterialpbrlearning-series

上一篇 拆了 Scene / Camera / Renderer。 这一篇拆 Mesh 的两个组成部分:Geometry(形状)+ Material(外观)。 搞清楚这两个之后,你才能真正理解 GPU 在每一帧到底"看到"什么、又是怎么"画"出来的。

一、BufferGeometry:顶点数据的真相

1.1 一切几何体都是"一堆数字"

GPU 不认识"立方体"、"球"、"龙"——它只认识 三角形,而三角形由顶点组成。 所以无论你用 BoxGeometry 还是加载一个龙模型,最终送进 GPU 的都是:

positions : [x0, y0, z0, x1, y1, z1, x2, y2, z2, ……]   ← 顶点位置
normals   : [nx0, ny0, nz0, ……]                       ← 顶点法线(用于光照)
uvs       : [u0, v0, u1, v1, ……]                      ← 纹理坐标
indices   : [0, 1, 2, 2, 3, 0, ……]                    ← 三角形索引(可选)
colors    : [r0, g0, b0, ……]                          ← 顶点颜色(可选)

这就是 Three.js 里的 BufferGeometry —— 所有几何体的基类,所有内置 BoxGeometry / SphereGeometry / TorusGeometry 都继承自它

1.2 BufferAttribute:每个数组的"包装器"

打开 src/core/BufferGeometry.js, 你会看到 BufferGeometry 内部不是直接存 Float32Array,而是用 BufferAttribute 包了一层:

class BufferGeometry {
  attributes = {}    // { position: BufferAttribute, normal: ..., uv: ... }
  index      = null  // BufferAttribute | null
  groups     = []    // 分材质多组渲染时用
}

class BufferAttribute {
  array       // 实际数据(Float32Array / Uint16Array ……)
  itemSize    // 每个顶点占几个数字(position = 3, uv = 2, color = 3)
  count       // 顶点数
  normalized  // 是否需要标准化(针对整型数组)
  usage       // STATIC_DRAW / DYNAMIC_DRAW
}

itemSize 是关键——告诉 GPU "每 3 个数字是一个顶点位置,每 2 个数字是一个 uv"

1.3 一个最小的"手工 BufferGeometry"

直接造一个三角形:

const geometry = new THREE.BufferGeometry()

// 一个三角形 = 3 个顶点 × 3 个数字(x, y, z)= 9 个数字
const vertices = new Float32Array([
  -1, -1, 0,
   1, -1, 0,
   0,  1, 0,
])

geometry.setAttribute(
  'position',
  new THREE.BufferAttribute(vertices, 3)   // 3 = itemSize
)

const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ color: 0x44aa88 }))
scene.add(mesh)

跑起来——一个绿色三角形出现在屏幕上。 这就是 Three.js 最底层的几何体构造方式—— 所有内置 geometry 都是用类似代码批量生成的。

理解这一点之后,再去看 BoxGeometry / SphereGeometry 的源码: 它们本质上是用循环生成上面这种 Float32Array 的工具函数。 自定义几何体(地形、波浪、自适应网格)就靠手动操作 BufferAttribute 来实现。

1.4 索引(Index):去重的关键

立方体有 8 个顶点,但 6 个面 × 2 个三角形 = 12 个三角形 × 3 = 36 个顶点位置。 要么重复存 36 个,要么存 8 个 + 一个"索引数组"。

geometry.setIndex([0, 1, 2,  2, 3, 0, ...])  // 用索引复用顶点

带索引的几何体内存占用小、缓存命中率高—— 是为什么 BoxGeometry 默认带 index。 GPU 通过 gl.drawElements() 而不是 gl.drawArrays() 来渲染索引化几何体。

1.5 内置 Geometry 速查表

按使用频率排序:

用途关键参数
BoxGeometry立方体width, height, depth, segments
SphereGeometryradius, widthSegments, heightSegments
PlaneGeometry平面width, height, segments(做地面/水面常用
CylinderGeometry圆柱radiusTop, radiusBottom, height
ConeGeometry圆锥同上但 radiusTop=0
TorusGeometry圆环(甜甜圈)radius, tube
TorusKnotGeometry圆环结数学装饰常用
TetrahedronGeometry四面体多面体家族
OctahedronGeometry八面体多面体家族
IcosahedronGeometry二十面体球体的低多边形替代
LatheGeometry旋转曲面用 2D 轮廓绕轴旋转生成
ExtrudeGeometry拉伸从 2D Shape 拉成 3D
TextGeometry3D 文字需要字体文件
EdgesGeometry边线提取从已有几何提取边
WireframeGeometry线框同上

每一个都是 src/geometries/ 下一个独立文件—— 读源码会发现它们都是100–300 行的循环 + 三角函数,理解一两个就懂全套。

二、Material:决定"怎么画"的策略

Geometry 决定形状,Material 决定外观—— 同一个球,配 BasicMaterial 是纯色,配 StandardMaterial 是金属球,配 ShaderMaterial 可以是流动液体。

2.1 Material 的家族族谱

Material(基类)
  ├── MeshBasicMaterial       不参与光照,纯色或贴图
  ├── MeshNormalMaterial      用法线着色(调试用)
  ├── MeshDepthMaterial       用深度着色(调试/阴影)
  ├── MeshMatcapMaterial      用 matcap 球贴图模拟光照
  ├── MeshLambertMaterial     漫反射(旧式 Lambertian 光照模型)
  ├── MeshPhongMaterial       漫反射 + 高光(Phong 光照模型)
  ├── MeshToonMaterial        卡通渲染(色阶分层)
  ├── MeshStandardMaterial    ★ PBR 标准材质(推荐用这个)
  ├── MeshPhysicalMaterial    PBR 扩展(玻璃、布料、清漆涂层)
  ├── ShaderMaterial          自定义 GLSL(Part 07 详谈)
  ├── RawShaderMaterial       完全裸的自定义 shader
  ├── LineBasicMaterial       线段
  ├── LineDashedMaterial      虚线
  ├── PointsMaterial          点云
  ├── SpriteMaterial          始终面向相机的精灵
  └── ShadowMaterial          仅接收阴影

每一种都有特定用途,但 90% 的项目主要在用 4 种

  • MeshBasicMaterial —— 不需要光照的简单场景
  • MeshStandardMaterial —— 写实风格,配光使用
  • ShaderMaterial —— 特效、流动、变形
  • LineBasicMaterial —— 线框、辅助线、debug

2.2 MeshBasicMaterial:最朴素的着色策略

const mat = new THREE.MeshBasicMaterial({
  color: 0xff8844,
  wireframe: false,
  transparent: false,
  opacity: 1,
  map: null,        // 颜色贴图(texture)
  side: THREE.FrontSide,  // 双面:DoubleSide
})

特点:完全无视场景里的光源——给颜色就是颜色,给贴图就是贴图。 适合:UI、标记、不需要光照感的简单场景、地图标记球。

2.3 MeshLambertMaterial / MeshPhongMaterial:经典光照模型

这两个是"古典图形学"时代的材质:

  • Lambert:只算漫反射(cosθ 决定亮度),表面看起来"哑光"
  • Phong:Lambert + 高光(specular highlight),表面看起来"塑料"或"金属"
const lambert = new THREE.MeshLambertMaterial({ color: 0x44aa88 })
const phong = new THREE.MeshPhongMaterial({
  color: 0x44aa88,
  specular: 0x222222,
  shininess: 30,
})

特点:计算便宜,但物理上不准—— 现实里光照不是简单的 "cosθ" 关系。所以现在主流是 PBR。

2.4 MeshStandardMaterial:PBR 时代的默认选择

PBR(Physically Based Rendering) 是过去 10 年图形学最大的范式转移—— 把"凭手感调参数"换成"基于真实物理的光照计算"。

const mat = new THREE.MeshStandardMaterial({
  color: 0xffffff,
  metalness: 0.8,     // 金属度: 0 = 非金属(塑料/木头), 1 = 纯金属
  roughness: 0.2,     // 粗糙度: 0 = 镜面, 1 = 完全漫反射

  map:           colorTexture,        // 颜色贴图
  normalMap:     normalTexture,       // 法线贴图(凹凸细节)
  roughnessMap:  roughnessTexture,    // 粗糙度贴图
  metalnessMap:  metalnessTexture,    // 金属度贴图
  aoMap:         aoTexture,            // 环境光遮蔽
  emissiveMap:   emissiveTexture,     // 自发光
  envMap:        envCubeTexture,      // 环境反射(必需,否则金属看起来怪)
})

两个核心参数metalness + roughness。 PBR 把"什么是金属、表面有多光滑"这两件事量化成 0–1 的数字—— 所有材质的视觉效果由这两个参数主导。

metalness=0, roughness=1   →   哑光塑料
metalness=0, roughness=0   →   光滑塑料/陶瓷
metalness=1, roughness=0   →   抛光金属(镜面)
metalness=1, roughness=1   →   磨砂金属

PBR 必须配环境贴图(envMap)—— 没有 envMap 的 metalness > 0 物体看起来会"灰扑扑、像油漆"。 因为金属的外观 90% 来自反射的环境光,没东西可反射就什么都看不到。 实战中用 RGBELoader 加载 .hdr 文件作 envMap。

2.5 MeshPhysicalMaterial:PBR Pro

MeshPhysicalMaterial 继承 Standard,多加了几个高级特性

new THREE.MeshPhysicalMaterial({
  clearcoat: 1,            // 清漆涂层(汽车漆、指甲油)
  clearcoatRoughness: 0.1,
  transmission: 1,         // 透射(玻璃、酒杯)
  thickness: 0.5,          // 透射厚度
  ior: 1.5,                // 折射率
  sheen: 0.5,              // 布料光泽(天鹅绒、丝绸)
  iridescence: 0.5,        // 虹彩(CD、肥皂泡)
  attenuationColor: 0xffffff,
  attenuationDistance: 1,
})

适用:汽车、玻璃、宝石、布料、肥皂泡—— 任何"普通 PBR 不够用" 的特殊材质。

2.6 MeshNormalMaterial / MeshDepthMaterial:调试神器

new THREE.MeshNormalMaterial()    // 用法线方向着色(每面颜色不同)
new THREE.MeshDepthMaterial()     // 用深度着色(近黑远白)

虽然不是"产品材质",但写复杂场景时是定位 bug 的好工具

  • 模型导入后看起来怪 → 换 NormalMaterial 看法线对不对
  • 阴影/深度有问题 → 换 DepthMaterial 看深度分布

2.7 LineBasicMaterial / PointsMaterial / SpriteMaterial

这三个不是给 Mesh 用的:

  • LineBasicMaterial + THREE.Line / LineSegments / LineLoop 画线
  • PointsMaterial + THREE.Points 画点云
  • SpriteMaterial + THREE.Sprite 画始终面向相机的精灵图
// 一万个点的星空
const positions = new Float32Array(10000 * 3)
for (let i = 0; i < positions.length; i++) positions[i] = (Math.random() - 0.5) * 100
const geom = new THREE.BufferGeometry()
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3))
const stars = new THREE.Points(geom, new THREE.PointsMaterial({ size: 0.1, color: 0xffffff }))
scene.add(stars)

Points + 大量顶点是粒子系统、星空、烟雾的基础—— 配合 ShaderMaterial 能做出非常炫的效果(Part 07 会展开)。

三、Texture:贴图系统的核心

PBR 材质几乎都要配贴图。Three.js 的 Texture 系统几个要点:

3.1 加载贴图

const loader = new THREE.TextureLoader()
const tex = loader.load('/path/to/texture.jpg')

// r152+ 必须显式设置 colorSpace
tex.colorSpace = THREE.SRGBColorSpace  // 颜色贴图用 sRGB
// normalMap / roughnessMap 等"数据贴图"用 NoColorSpace(默认)

三个最容易忘的配置

  • colorSpace —— 颜色贴图必须 sRGB,数据贴图必须 NoColorSpace
  • flipY —— GLTF 加载的纹理是 false,常规贴图是 true
  • wrapS / wrapT —— 默认 ClampToEdge,平铺要改 RepeatWrapping

3.2 贴图类型一览

贴图作用数据类型
map颜色(diffuse)sRGB
normalMap法线(凹凸细节,不改几何)Linear
roughnessMap粗糙度Linear
metalnessMap金属度Linear
aoMap环境光遮蔽Linear
emissiveMap自发光sRGB
bumpMap凸凹(老式,不如 normal)Linear
displacementMap位移(真的改变顶点)Linear
envMap环境反射sRGB
lightMap烘焙光照Linear
alphaMap透明度Linear

3.3 mipmap 与 anisotropy

tex.generateMipmaps = true
tex.minFilter = THREE.LinearMipmapLinearFilter
tex.anisotropy = renderer.capabilities.getMaxAnisotropy()  // 各向异性过滤
  • mipmap:自动生成多个层级的小图,远处用小图,避免锯齿和摩尔纹
  • anisotropy:斜视贴图时的清晰度(看远处地面纹理特别明显)

四、Material 的"GLSL 编译"是怎么发生的

很多人不知道:每个 Material 内部都会被 Three.js 编译成一段 GLSL shader

打开 src/renderers/shaders/ShaderLib.js, 你会看到一张"材质 → shader 名"的映射表:

const ShaderLib = {
  basic:     { uniforms, vertexShader, fragmentShader },
  lambert:   { ... },
  phong:     { ... },
  standard:  { ... },
  physical:  { ... },
  matcap:    { ... },
  toon:      { ... },
  points:    { ... },
  // ……
}

每个 shader 由 chunk(片段) 拼接而成。例如 standard 材质的 fragment shader 大致结构:

common.glsl.js
+ packing.glsl.js
+ uv_pars_fragment.glsl.js
+ map_pars_fragment.glsl.js
+ normalmap_pars_fragment.glsl.js
+ ……
+ standard_frag.glsl.js

打开 src/renderers/shaders/ShaderChunk/ 就能看到 100+ 个 .glsl.js 片段—— Three.js 的 PBR 实现就藏在这里

这是 Three.js 工程上最精巧的一部分: 把 shader 切成可复用的 chunk,按材质需求"编译期拼接"。 这种模式后来被很多 WebGL 库借鉴。 Part 07 会专门讲怎么 inject 你自己的 chunk 进现有材质—— 这是写"PBR 材质 + 自定义变形"最优雅的姿势。

五、实战:四种材质的同球对比

放四个相同的球到场景里,分别用四种材质——一眼看出区别:

const ball = (mat, x) => {
  const m = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 32), mat)
  m.position.x = x
  scene.add(m)
  return m
}

ball(new THREE.MeshBasicMaterial({ color: 0xff4444 }), -3)
ball(new THREE.MeshLambertMaterial({ color: 0xff4444 }), -1)
ball(new THREE.MeshPhongMaterial({ color: 0xff4444, shininess: 100 }), 1)
ball(new THREE.MeshStandardMaterial({ color: 0xff4444, metalness: 0.8, roughness: 0.2 }), 3)

// 不加光的话 Lambert/Phong/Standard 都是黑的,必须加灯
scene.add(new THREE.AmbientLight(0xffffff, 0.3))
const dir = new THREE.DirectionalLight(0xffffff, 1)
dir.position.set(5, 5, 5)
scene.add(dir)

// 加环境贴图让 Standard 的金属感正常
new THREE.RGBELoader().load('/env.hdr', tex => {
  tex.mapping = THREE.EquirectangularReflectionMapping
  scene.environment = tex
})

跑完看四个球—— Basic 是平面色,Lambert 是哑光,Phong 是塑料,Standard 是金属球。 这就是材质系统给"同一个几何体"带来的视觉表达力。

六、自定义 Geometry:做一个波浪平面

实际项目里经常需要"动态变形"的几何——海洋、地形、布料。 最朴素的做法是操作 BufferAttribute

const SIZE = 50, SEG = 100
const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEG, SEG)
geo.rotateX(-Math.PI / 2)  // 让它平躺

const positions = geo.attributes.position
const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
  color: 0x2266aa, wireframe: false, side: THREE.DoubleSide,
}))
scene.add(mesh)

// 每帧更新顶点高度
const clock = new THREE.Clock()
renderer.setAnimationLoop(() => {
  const t = clock.getElapsedTime()
  for (let i = 0; i < positions.count; i++) {
    const x = positions.getX(i)
    const z = positions.getZ(i)
    positions.setY(i, Math.sin(x * 0.3 + t) * 0.5 + Math.cos(z * 0.3 + t * 0.7) * 0.5)
  }
  positions.needsUpdate = true   // ★ 关键:通知 GPU 重新上传数据
  geo.computeVertexNormals()     // 重算法线,否则光照不对
  renderer.render(scene, camera)
})

要点:

  • positions.needsUpdate = true ★ 必须,不然 GPU 不会重新拿数据
  • 改完位置要 computeVertexNormals(),否则光照计算用的还是旧法线
  • 如果性能不够:把这套逻辑搬到 ShaderMaterial 的 vertex shader 里——CPU → GPU 是数量级提升(Part 07 详谈)

七、作业

Part 02 的基础上:

1. 手工造一个金字塔几何体

不用 ConeGeometry,用 BufferGeometry + 5 个顶点 + index 自己拼一个四棱锥。 做完会非常有感——"几何体不是黑盒"。

2. PBR 球 + 三种贴图

下载一组免费 PBR 贴图(ambientCG / polyhaven), 分别挂上 map / normalMap / roughnessMap,加 envMap,调出"金属磨砂表面"的效果。

3. 一千个 cube 用 MeshNormalMaterial

随机放 1000 个不同位置、不同大小的立方体—— 你会发现 1000 个 cube 各自一个 drawcall 时,FPS 可能掉到很难看。 这是 Part 09 性能优化 要解决的问题,先体会一下"野生 Three.js"的性能边界。

八、下一篇预告

Part 04:光照与阴影 会回答:

  • 三大光(Ambient / Directional / Point / Spot / Hemisphere / RectArea)各自适合什么场景
  • 阴影贴图(Shadow Map)为什么默认是关的
  • 软阴影、PCF / VSM、级联阴影(CSM)
  • IBL(基于图像的光照)和 envMap 的关系
  • 实战:搭一个完整的"展厅灯光"场景

PBR 离开光照就是"半成品"——下一篇才真正让你的场景有"质感"。

一句话总结

Geometry 是顶点数据,Material 是着色策略。 看懂 BufferGeometry 的数组结构,你就明白了 GPU 在看什么; 看懂 Material 家族族谱,你就明白了 GPU 在算什么。 这两件事搞清楚之后,Three.js 的所有视觉效果都不再是黑盒

延伸阅读

官方文档(按本文涉及类查)

源码核心文件

PBR 与图形学深读

资源站

系列内文章