three.js DecalGeometry 详解:将贴花任意投射到网格表面的原理实现与实战案例
DecalGeometry 是 three.js 中的一个 addon 几何体,用于把一块"贴花"(decal)从指定位置与朝向投影到任意目标网格表面,生成贴合目标曲面的新几何。本文基于官方文档 DecalGeometry.html.md 与仓库中的实际源码 DecalGeometry.js,完整讲解其构造参数、三步式生成算法(顶点空间变换、六平面裁剪、UV 生成),并结合官方示例 webgl_decals.html 演示如何做出"点击喷溅贴花"的效果。读完后你将掌握贴花投影的底层机制、与射线拾取配合的实战写法,以及使用中的已知限制与注意事项。
一、DecalGeometry 是什么
官方文档对该类的定位是:
This class can be used to create a decal mesh that serves different kinds of purposes e.g. adding unique details to models, performing dynamic visual environmental changes or covering seams.
即创建一个"贴花网格",典型用途包括:
- 为模型添加独特细节(如墙面弹孔、地面油渍、车身贴纸);
- 动态的视觉环境变化(如运行时随机泼洒、覆盖物);
- 遮盖接缝(covering seams,例如模型 UV 拼接处的瑕疵)。
实现原理可类比相机投影:以 position 和 orientation 定义一个"贴花投影仪"(projector),以 size 定义其投射体积(一个轴对齐的长方体),然后把目标网格中落在该体积内的部分"剥"下来,生成一个自带 position、uv、normal 属性的新 BufferGeometry。
需要特别注意的是官方明确指出的限制:当贴花跨越拐角(around corners)时,投影会出现失真(distortion)。这一点在源码层面也是成立的——它是对目标三角面做体积裁剪而非逐像素光线投射,跨面转折处无法保证无畸变。
继承链为:EventDispatcher → BufferGeometry → DecalGeometry。
二、导入与快速上手
DecalGeometry 属于 addon(附加模块),不在 three 主包中,必须从 three/addons 路径显式导入(对应官方手册 Installation#Addons 一节的要求):
import { DecalGeometry } from 'three/addons/geometries/DecalGeometry.js';
官方文档给出的最小示例:
const geometry = new DecalGeometry( mesh, position, orientation, size );
const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
const mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );
注意一个容易踩坑的细节:贴花几何体在构造时就已把顶点烘焙(bake)到世界空间(详见下文源码分析)。因此上例直接把结果加到 scene 只适合"目标网格不动"的场景;如果目标网格后续还会移动/旋转,推荐做法(官方示例采用)是把贴花 attach 到目标网格下,让两者共享变换,第六节有完整写法。
三、构造参数详解
构造函数签名(DecalGeometry.js):
new DecalGeometry( mesh : Mesh, position : Vector3, orientation : Euler, size : Vector3 )
结合源码中的默认值 constructor( mesh = new Mesh(), position = new Vector3(), orientation = new Euler(), size = new Vector3( 1, 1, 1 ) ),四个参数说明如下:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
mesh |
Mesh |
new Mesh() |
贴花要投射到的目标网格。其 geometry 需含 position 属性;normal 属性可选,存在时贴花会带上法线,否则生成的几何没有 normal 属性 |
position |
Vector3 |
new Vector3() |
贴花投影仪的位置(世界空间) |
orientation |
Euler |
new Euler() |
贴花投影仪的朝向,通过 makeRotationFromEuler 生成旋转矩阵 |
size |
Vector3 |
new Vector3( 1, 1, 1 ) |
贴花投影仪的尺度,决定裁剪体积的半轴长度,同时参与 UV 计算(见下节) |
从源码看,size 的双重作用值得注意:
- 裁剪体积:在
clipGeometry中半轴长度s = 0.5 * Math.abs( size.dot( plane ) ),即六个裁剪平面与投影仪中心相距size/2,构成一个size大小的长方体投影视锥; - UV 映射:最终 UV 按
0.5 + x / size.x、0.5 + y / size.y生成,使投影仪正中心(projector 空间的 (0,0))恰好映射到 UV 的(0.5, 0.5)。因此size越大,同一张贴花纹理被"拉伸"得越开。
另外,目标网格的 matrixWorld 会参与顶点变换,构造前请确保它已经更新(必要时调用 mesh.updateMatrixWorld()),否则贴花位置会错位。
四、源码级原理:三步生成流水线
整个几何生成发生在构造函数内部,核心是 generate() 函数。流程分为三步:
4.1 建立 DecalVertex 阵列:世界空间 → 投影仪空间
首先根据目标 geometry 是否有 index 区分处理路径:
- 有索引的 BufferGeometry:按
index逐顶点取出position(及可选normal),共index.count个; - 无索引的 BufferGeometry:直接按
positionAttribute.count遍历;若position属性不存在则视为空几何直接返回。
每取出一个顶点,都通过 pushDecalVertex(L189-L207)做两次变换:
// transform the vertex to world space, then to projector space
vertex.applyMatrix4( mesh.matrixWorld );
vertex.applyMatrix4( projectorMatrixInverse );
即先乘目标的 matrixWorld 变换到世界空间,再乘投影仪矩阵的逆(projectorMatrixInverse)变换到投影仪局部空间。其中投影仪矩阵由朝向与位置合成(L58-L63):
const projectorMatrix = new Matrix4();
projectorMatrix.makeRotationFromEuler( orientation );
projectorMatrix.setPosition( position );
const projectorMatrixInverse = new Matrix4();
projectorMatrixInverse.copy( projectorMatrix ).invert();
法线则单独用 Matrix3.getNormalMatrix( mesh.matrixWorld )(L54)做正确的法线空间变换(applyNormalMatrix),避免了非均匀缩放下直接乘 matrixWorld 导致的法线失真。
结果存入 DecalVertex 对象(文件末尾的 辅助类,含 position 与可空的 normal 两个字段及 clone() 方法)。注释里说明了这一数据结构的意义:"三个连续的 DecalVertex 对象表示一个面",后续裁剪按面(3 个顶点一组)进行。
4.2 六平面裁剪:把几何限制在投影仪体积内
拿到全部 DecalVertex 后,依次用投影仪空间的六个轴对齐平面裁剪(L151-L156):
decalVertices = clipGeometry( decalVertices, plane.set( 1, 0, 0 ) );
decalVertices = clipGeometry( decalVertices, plane.set( - 1, 0, 0 ) );
decalVertices = clipGeometry( decalVertices, plane.set( 0, 1, 0 ) );
decalVertices = clipGeometry( decalVertices, plane.set( 0, - 1, 0 ) );
decalVertices = clipGeometry( decalVertices, plane.set( 0, 0, 1 ) );
decalVertices = clipGeometry( decalVertices, plane.set( 0, 0, - 1 ) );
六次裁剪后,剩余顶点全部落在以原点为中心、边长为 size 的长方体内——这正是"贴花投影仪"的投射范围。
clipGeometry 按面遍历顶点(每次步进 3 个),计算每个顶点到裁剪平面的有符号距离 d = position.dot( plane ) - s(s 为半轴长 0.5 * |size · plane|),按面内"出界顶点数"分四种情况处理:
| 情况 | 面内出界顶点数 | 处理 |
|---|---|---|
case 0 |
0 | 整面在体内,三个顶点原样输出 |
case 1 |
1 | 对出界顶点调用 clip() 求出与平面的交点,把该面拆成两个三角形输出(共 6 个顶点) |
case 2 |
2 | 仅保留在体内的顶点与两个交点构成的三角形 |
case 3 |
3 | 整面在体外,丢弃 |
其中 clip( v0, v1, p, s )(L360-L392)用线性插值求交点:s0 = d0 / ( d0 - d1 ),位置取 v0 + s0 * ( v1 - v0 );法线若两端都存在则同样线性插值。源码注释还贴心地指出,如果还需要裁剪更多属性(如纹理坐标),可以沿用同一模式 intersectpoint.value = a.value + s * ( b.value - a.value )。
裁剪完成后,剩余顶点即为贴花的完整顶点集(顶点数可能因拆分而增加,且不再与目标网格共享索引——最终生成的 DecalGeometry 是一个非索引几何,每个顶点独立持有 UV 与法线)。
4.3 生成最终 buffer:UV、位置回写、法线
最后一步遍历裁剪结果(L160-L185):
// create texture coordinates (we are still in projector space)
uvs.push(
0.5 + ( decalVertex.position.x / size.x ),
0.5 + ( decalVertex.position.y / size.y )
);
// transform the vertex back to world space
decalVertex.position.applyMatrix4( projectorMatrix );
vertices.push( decalVertex.position.x, decalVertex.position.y, decalVertex.position.z );
if ( decalVertex.normal !== null ) {
normals.push( decalVertex.normal.x, decalVertex.normal.y, decalVertex.normal.z );
}
三个要点:
- UV 在投影仪空间中计算:以投影仪正前方为中心,纹理 UV 的
(0.5, 0.5)对准投影仪中心,size决定 UV 的缩放密度; - 顶点再乘正向
projectorMatrix变回世界空间,写入position属性; - 法线属性是可选的:只有当目标网格提供了
normal属性时,最终几何才会setAttribute( 'normal', ... )(L74-L78)。如果你用无光照的材质(如MeshBasicMaterial)则无所谓,但用MeshPhongMaterial/MeshStandardMaterial等依赖法线的材质时,目标网格缺法线会导致光照异常。
最终写入的 buffer 属性为:position(必选,Float32BufferAttribute(vertices, 3))、uv(必选,2 分量)、normal(可选,3 分量)。
五、实战案例:点击喷溅贴花(webgl_decals)
仓库内置的 webgl_decals.html(官方示例页 decal splatter)是理解 DecalGeometry 用法的最佳参照:加载一栋房屋 GLB 模型后,鼠标点击即可在墙面"喷"上一块随机颜色、随机大小的贴花。其关键实现如下。
5.1 用射线拾取确定 position / orientation / size
DecalGeometry 的前两个几何参数直接来自射线检测结果(L178-L219):
raycaster.setFromCamera( mouse, camera );
raycaster.intersectObject( mesh, false, intersects );
if ( intersects.length > 0 ) {
const p = intersects[ 0 ].point;
// ...
const normalMatrix = new THREE.Matrix3().getNormalMatrix( mesh.matrixWorld );
const n = intersects[ 0 ].face.normal.clone();
n.applyNormalMatrix( normalMatrix );
n.multiplyScalar( 10 );
n.add( intersects[ 0 ].point );
mouseHelper.lookAt( n ); // 一个"探针"网格,朝向命中点法线方向
// ...
}
mouseHelper 是一个 BoxGeometry( 1, 1, 10 ) 的探针网格,通过 lookAt( 法线方向 ) 摆正姿态,点击时直接取它的 rotation 作为投影仪的 orientation(L258-L278):
function shoot() {
position.copy( intersection.point ); // 命中点 → position
orientation.copy( mouseHelper.rotation ); // 命中面朝向 → orientation
if ( params.rotate ) orientation.z = Math.random() * 2 * Math.PI; // 随机翻滚
const scale = params.minScale + Math.random() * ( params.maxScale - params.minScale );
size.set( scale, scale, scale ); // 随机尺度 → size
const material = decalMaterial.clone();
material.color.setHex( Math.random() * 0xffffff );
const m = new THREE.Mesh( new DecalGeometry( mesh, position, orientation, size ), material );
m.renderOrder = decals.length; // give decals a fixed render order
decals.push( m );
mesh.attach( m ); // 挂到目标网格下,跟随其变换
}
这段代码把文档中的四个构造参数全部"实战化"了:position 取射线命中点、orientation 取命中面法线朝向、size 取 GUI 可调的随机尺度(示例中 10~20)。
5.2 贴花材质与渲染顺序
示例中贴花使用 MeshPhongMaterial 并加载了专用贴图(examples/textures/decal/ 下的 decal-diffuse.png 与 decal-normal.jpg),几个关键设置值得借鉴:
const decalMaterial = new THREE.MeshPhongMaterial( {
specular: 0x444444,
map: decalDiffuse,
normalMap: decalNormal,
normalScale: new THREE.Vector2( 1, 1 ),
shininess: 30,
transparent: true,
depthTest: true,
depthWrite: false, // 贴花不写深度,避免遮挡后续贴花
polygonOffset: true, // 多边形偏移,防止与目标表面 z-fighting
polygonOffsetFactor: - 4,
wireframe: false
} );
transparent: true+ 贴花纹理自带透明区域,让贴花呈现不规则喷溅边缘;depthWrite: false保证多个贴花叠加时互不深度遮挡;polygonOffset系列参数把贴花面略微推离表面,解决共面 z-fighting——这是贴花贴附在模型表面上必须处理的经典问题。
配合 m.renderOrder = decals.length 给每个贴花固定渲染顺序,叠加顺序稳定可预期。示例还支持 GUI 清除全部贴花(removeDecals() 逐个 mesh.remove( d )),对应运行效果可参考官方截图 webgl_decals.jpg。
5.3 用 attach 解决"几何被烘焙"问题
DecalGeometry 的顶点在构造时就写死在世界空间,若目标网格此后发生变换,直接挂在 scene 下的贴花会"掉队"。示例的解法是 mesh.attach( m ):把贴花网格挂到目标网格的子级,attach 会自动把子对象的局部变换换算为新的 parent 坐标系,贴花便能随目标网格整体移动、旋转。删除时用 mesh.remove( d ) 对应移除。
六、已知限制与注意事项
综合官方文档与源码,实际使用时请注意:
- 拐角处失真:官方文档明确提示 "decal projections can be distorted when used around corners"(文档中同时引用了 wolfire 博客 How to project decals 的投影原理及 three.js 关于无失真贴花投影的 issue 讨论)。原因是六平面体积裁剪是"体"级别的操作,无法处理同一贴花横跨多个不同朝向面的转折。
- 几何是静态的:目标网格的变形(morph、物理模拟等)不会反映到已生成的贴花上;需要重新构造。
- 目标网格需带法线才有光照:
normal属性是可选输出,用光照材质时请确保源网格的geometry.attributes.normal存在且matrixWorld已更新。 size同时控制裁剪范围与 UV 密度:想让贴图更"密"时不要只调材质,应调大size(UV 按1/size缩放)。- 空几何安全:非索引且无
position属性的几何会被静默跳过,生成空几何(L126),不会抛错。
七、小结与相关文件
DecalGeometry 以约 420 行代码实现了一套完整的"投影仪体积裁剪"算法:顶点经 matrixWorld 与 projectorMatrixInverse 双变换进入投影仪空间,经六个轴对齐平面裁剪出体积内的三角面,再按 size 归一化生成 UV 并回写世界空间,最终输出带 position/uv/(可选)normal 的非索引 BufferGeometry。它与 Raycaster 命中点、面法线、MeshPhongMaterial 的透明/深度/偏移配置组合,就能实现游戏中常见的弹孔、泼溅、贴纸等效果。
本文涉及的仓库文件:
- 文档:docs/pages/DecalGeometry.html.md
- 源码:examples/jsm/geometries/DecalGeometry.js
- 示例:examples/webgl_decals.html
- 贴图资源:examples/textures/decal/decal-diffuse.png、examples/textures/decal/decal-normal.jpg
- 效果截图:examples/screenshots/webgl_decals.jpg
(本文基于当前仓库版本 0.185.0 的源码与文档编写。)
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00