Three.js 中自定义 UV 坐标贴图 — 超详细举例与解析
下面是一份面向工程实践的 Three.js 自定义 UV(纹理坐标) 教程,从基础概念、常见陷阱到 BufferGeometry 级别的示例(含贴图图集 atlas、每面不同贴图、Lightmap/uv2、球形展开等)。每个代码示例都可以直接复制到你的工程里运行(Three.js rXXX 常用 API)。
核心概念回顾(必须先懂)
- UV 是 2D 纹理坐标:通常记为
(u, v),范围通常是[0,1](可以超出,用于平铺/重复)。u→ 水平方向(纹理 X),v→ 垂直方向(纹理 Y)。 - UV 对每个顶点:每个网格顶点有一个 UV 坐标。不同的三角形即使共享顶点,也可以通过顶点拆分(duplicate vertices)来拥有不同 UV,从而形成“缝”。
- Three.js 中用
uv属性存放:BufferGeometry的attributes.uv是Float32BufferAttribute,长度 = 顶点数 × 2。 - 注意
flipY与纹理方向:Three.js 加载图片时默认texture.flipY = true(基于传统 WebGL纹理坐标)。若你手工设置 UV,或贴图来自外部软件,可能需调整texture.flipY = false。 - 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的使用(v1↔v0交换)。
示例 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 上,且 MeshStandardMaterial 或 MeshLambertMaterial 等需要第二组 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 上做更多操作
在 ShaderMaterial 或 onBeforeCompile 中直接变换传入的 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 分布。