Three.js 中自定义 UV 坐标贴图 — 超详细举例与解析

下面是一份面向工程实践的 Three.js 自定义 UV(纹理坐标) 教程,从基础概念、常见陷阱到 BufferGeometry 级别的示例(含贴图图集 atlas、每面不同贴图、Lightmap/uv2、球形展开等)。每个代码示例都可以直接复制到你的工程里运行(Three.js rXXX 常用 API)。


核心概念回顾(必须先懂)

  1. UV 是 2D 纹理坐标:通常记为 (u, v),范围通常是 [0,1](可以超出,用于平铺/重复)。u → 水平方向(纹理 X),v → 垂直方向(纹理 Y)。
  2. UV 对每个顶点:每个网格顶点有一个 UV 坐标。不同的三角形即使共享顶点,也可以通过顶点拆分(duplicate vertices)来拥有不同 UV,从而形成“缝”。
  3. Three.js 中用 uv 属性存放BufferGeometryattributes.uvFloat32BufferAttribute,长度 = 顶点数 × 2。
  4. 注意 flipY 与纹理方向:Three.js 加载图片时默认 texture.flipY = true(基于传统 WebGL纹理坐标)。若你手工设置 UV,或贴图来自外部软件,可能需调整 texture.flipY = false
  5. uv2 用于光照贴图(lightMap):如果要使用 MeshStandardMaterial.lightMap,需要提供第二组 UV:uv2 属性。

示例 1 — 基本:给 Plane 手动赋 UV(自定义四角坐标)

最简单的场景:创建一个平面并手动设置四个顶点的 UV(左下(0,0) 右上(1,1) 等):

// 假设 THREE 已经导入并创建了 scene, camera, renderer
const geometry = new THREE.BufferGeometry();

// 四个顶点(两个三角形)
const positions = new Float32Array([
  -1, -1, 0, // 0: 左下
   1, -1, 0, // 1: 右下
   1,  1, 0, // 2: 右上
  -1,  1, 0  // 3: 左上
]);

// 三角形索引
const indices = new Uint16Array([0, 1, 2, 0, 2, 3]);

// UV:按顶点顺序设置 (u, v)
const uvs = new Float32Array([
  0, 0,  // 顶点 0 -> 左下
  1, 0,  // 顶点 1 -> 右下
  1, 1,  // 顶点 2 -> 右上
  0, 1   // 顶点 3 -> 左上
]);

geometry.setIndex(new THREE.BufferAttribute(indices, 1));
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));

const texture = new THREE.TextureLoader().load('texture.jpg');
const mat = new THREE.MeshBasicMaterial({ map: texture });
const mesh = new THREE.Mesh(geometry, mat);
scene.add(mesh);

关键点uv 的长度要等于顶点数 × 2。若 geometry 有索引(index),UV 是按顶点索引对应的。


示例 2 — 编辑已有几何体的 UV(例如 BoxGeometry 每个面单独贴不同区域)

BoxGeometry 默认每个面有自己的 UV,但如果你想自定义每个面的 UV(例如做图集 atlas,把不同面指向贴图的不同区域):

假设我们使用一个 2×3 的图集(6 个小图),每个面取其中 1 个小图:

const box = new THREE.BoxGeometry(1, 1, 1);

// BoxGeometry 默认生成 24 个顶点(每个面4个各自独立)——这方便每个面有不同 UV
// 我们将重写 box.attributes.uv 的值
const uvs = box.attributes.uv.array; // Float32Array
// uvs.length = 24 * 2 = 48

// 假设我们的图集是 2 列(cols) × 3 行(rows)
const cols = 2, rows = 3;
function setFaceUV(faceIndex, col, row) {
  // faceIndex: 0..5(box 有 6 个面)
  // 每面4个顶点,uv 存储顺序与 BoxGeometry 构建时一致
  const tileW = 1 / cols;
  const tileH = 1 / rows;

  // 计算 uv 起始下标:每面 4 顶点 * 2 值
  const offset = faceIndex * 4 * 2;

  // 小图左下角的 uv 坐标
  const u0 = col * tileW;
  const v0 = row * tileH;
  const u1 = u0 + tileW;
  const v1 = v0 + tileH;

  // 注意 Three.js 的 UV 顶点排列,BoxGeometry 顺序通常为:
  // 0:(u0,v1), 1:(u1,v1), 2:(u1,v0), 3:(u0,v0)
  uvs[offset + 0] = u0; uvs[offset + 1] = v1;
  uvs[offset + 2] = u1; uvs[offset + 3] = v1;
  uvs[offset + 4] = u1; uvs[offset + 5] = v0;
  uvs[offset + 6] = u0; uvs[offset + 7] = v0;
}

// 为 6 个面分配图集格子
setFaceUV(0, 0, 0);
setFaceUV(1, 1, 0);
setFaceUV(2, 0, 1);
setFaceUV(3, 1, 1);
setFaceUV(4, 0, 2);
setFaceUV(5, 1, 2);

box.attributes.uv.needsUpdate = true;

const texture = new THREE.TextureLoader().load('atlas.png');
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
const mat = new THREE.MeshBasicMaterial({ map: texture });
const mesh = new THREE.Mesh(box, mat);
scene.add(mesh);

要点说明

  • BoxGeometry 默认并不是所有面共享顶点(为了便于每面独立贴图它会拆分顶点),因此直接操作 attributes.uv 很方便。
  • 图集坐标的 v 方向是否需要翻转取决于图片和 texture.flipY。若你发现贴反了,试试 texture.flipY = false 或调整 v 的使用(v1v0 交换)。

示例 3 — 使用图集 (Sprite Atlas) 的通用函数

常见场景:一个大图片里有 cols × rows 个小图,你想把某个 sprite 索引映射到 UV:

function computeAtlasUV(colIndex, rowIndex, cols, rows) {
  const tileW = 1 / cols;
  const tileH = 1 / rows;
  const u0 = colIndex * tileW;
  const v0 = rowIndex * tileH;
  const u1 = u0 + tileW;
  const v1 = v0 + tileH;
  return { u0, v0, u1, v1 };
}

// Example: 将某个平面指向 atlas 第 3 个格子(横优先)
const idx = 2;
const cols = 4, rows = 4;
const col = idx % cols;
const row = Math.floor(idx / cols);
const { u0, v0, u1, v1 } = computeAtlasUV(col, row, cols, rows);
// 然后像上面那样写入 uv buffer(注意 v 协调方向)


示例 4 — 修改已有几何体(geometry.attributes.uv)注意事项与调试方法

  • geometry 有索引(geometry.index),UV 数组对应顶点数组,不是三角形索引;索引仅决定绘制顺序。
  • 修改 geometry.attributes.uv.array[...] 后必须 geometry.attributes.uv.needsUpdate = true;
  • 使用 console.log(geometry.attributes.uv.array) 可以调试 UV 值。
  • 如果你看到纹理拉伸或缝隙,通常原因是:顶点没有为面拆分(即相邻面共享同一个顶点但需要不同 UV),这时需要拷贝顶点(duplicate vertices)或对 BufferGeometry 做展开。

示例 5 — 给自定义 BufferGeometry 直接设 UV(无索引示例)

很多教程使用“非索引(non-indexed)”三角形数组,这时每个三角形的顶点都是独立的,设 UV 更直观:

const geometry = new THREE.BufferGeometry();

const positions = new Float32Array([
  // 三角形 A
  -1, -1, 0,
   1, -1, 0,
   0,  1, 0,
  // 三角形 B (另一个)
  // ...
]);

const uvs = new Float32Array([
  // 对应 三角形 A 的三个顶点
  0, 0,
  1, 0,
  0.5, 1,
  // 三角形 B 的 uv...
]);

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));


示例 6 — uv2(第二组 UV)用于光照贴图(Lightmap)

当你有一个预烘焙的 lightmap(光照贴图),需要把它绑定到材质的 lightMap 上,且 MeshStandardMaterialMeshLambertMaterial 等需要第二组 UV (uv2):

// geometry 已经有 geometry.attributes.uv
// 假设你已经用建模工具生成了 second UV set,并放到 geometry.userData 或文件中。
// 简单方法:把 uv 复制一份成 uv2(注意:这只是复制 uv,不是正确的 lightmap UV 展开)
geometry.setAttribute('uv2', geometry.attributes.uv.clone());

const texture = new THREE.TextureLoader().load('albedo.jpg');
const lightMap = new THREE.TextureLoader().load('lightmap.jpg');

const mat = new THREE.MeshStandardMaterial({
  map: texture,
  lightMap: lightMap,
  lightMapIntensity: 1.0
});

const mesh = new THREE.Mesh(geometry, mat);
scene.add(mesh);

注意:正确的 uv2 通常要在 3D 建模软件中为 lightmap 生成无重叠、最大化空间的第二组 UV;简单复制 uv 只是示范用途。


示例 7 — 球面或圆柱的 UV 算法(手写展开)

如果你有自定义球体顶点,你可以手工计算 UV(球面展开常用公式):

对于球面上点 (x, y, z)(单位球):

  • u = 0.5 + atan2(z, x) / (2π)
  • v = 0.5 - asin(y) / π

示例(把顶点数组变为 UV):

function computeSphericalUV(x, y, z) {
  const u = 0.5 + Math.atan2(z, x) / (2 * Math.PI);
  const v = 0.5 - Math.asin(y) / Math.PI;
  return [u, v];
}

// 假设 positions 是 Float32Array(每 3 个一组)
const pos = geometry.attributes.position.array;
const count = pos.length / 3;
const uvs = new Float32Array(count * 2);
for (let i = 0; i < count; i++) {
  const x = pos[i * 3 + 0];
  const y = pos[i * 3 + 1];
  const z = pos[i * 3 + 2];
  const [u, v] = computeSphericalUV(x, y, z);
  uvs[i * 2 + 0] = u;
  uvs[i * 2 + 1] = v;
}
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geometry.attributes.uv.needsUpdate = true;

裂缝/缝合问题:球面的 atan2 会在经度边界产生 u 的跳跃(0 vs 1),若两边共享顶点,需要把顶点拆分(复制一组顶点,分别给 u≈0 和 u≈1),以避免三角形跨越经度出现纹理拉伸。


示例 8 — 动态运行时改变 UV(动画、滚动纹理但保留几何不变)

常用于做流水、跑马灯、移动图层:直接修改 uv 或使用 texture.offset/texture.repeat

方法 A:修改 texture.offset(最简单)

texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(1, 1);

function animate() {
  texture.offset.x += 0.01; // x 方向滚动
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}
animate();

方法 B:直接修改顶点 UV(更灵活,比如在 UV 上做波动)

const uvs = geometry.attributes.uv.array;
for (let i = 0; i < uvs.length; i += 2) {
  uvs[i + 1] += Math.sin(time + i) * 0.001; // 微动 v
}
geometry.attributes.uv.needsUpdate = true;


常见坑与调试清单(务必看)

  • UV范围与 Wrap:如果 UV 超出 [0,1],设置 texture.wrapS/T = THREE.RepeatWrapping 才会平铺;默认是 ClampToEdge
  • 纹理颠倒问题:图片看起来上下颠倒?试 texture.flipY = false 或交换 v 值。
  • 共享顶点导致贴图错位:若相邻面共享顶点但需要不同 UV,必须在几何体级别复制顶点(即使位置相同),从而 UV 可以不同。
  • 索引 vs 非索引:若使用 geometry.toNonIndexed(),每个三角形会有独立顶点,便于直接按三角形设置 UV(不过顶点重复增多)。
  • lightMap 需要 uv2:没有 UV2 会导致 lightMap 无效或错误。
  • Precision/边缘像素:图集tile边缘如果没有留白,线性滤波(LinearFilter)会溢出相邻像素导致“缝”。解决:在 atlas 中为每个 tile 预留 padding 或使用 nearest filter(但会模糊)。
  • 贴图 Wrap 与 Mipmaps:使用 Repeat + Mipmaps 时注意边界像素采样,仍可能取到邻近 tile 的像素 —— atlas 要有 padding。

进阶:使用着色器在 UV 上做更多操作

ShaderMaterialonBeforeCompile 中直接变换传入的 vUv

// vertex shader
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

// fragment shader
uniform sampler2D map;
varying vec2 vUv;
void main() {
  vec2 uv = vUv;
  // 例如旋转纹理中心
  float angle = 0.5;
  vec2 center = vec2(0.5);
  uv -= center;
  uv = mat2(cos(angle), -sin(angle), sin(angle), cos(angle)) * uv;
  uv += center;
  gl_FragColor = texture2D(map, uv);
}

这样你可以在 shader 里使用 UV 做任意效果(旋转、缩放、波动、切片等)。


小结(捷径 & 推荐实践)

  • 简单贴图变换:优先 texture.offset & texture.repeat(不改变几何)。
  • 每面不同贴图/图集:修改 attributes.uv(BoxGeometry 通常已拆分顶点,直接修改数组)。
  • 复杂 unwrap / lightmap:在建模工具(Blender、Maya)中展开 UV,并导出到模型文件(.glb/.gltf)以保留 uv2 等。
  • 避免 atlas 边缘污染:为每个 tile 添加 padding,或在 sampling 时使用 clamp/nearest,根据需要调整。
  • 调试技巧:在 fragment shader 中输出 vUv 颜色(gl_FragColor = vec4(vUv, 0.0,1.0);),直接观察 UV 分布。