首页
/ three.js DecalGeometry 详解:将贴花任意投射到网格表面的原理实现与实战案例

three.js DecalGeometry 详解:将贴花任意投射到网格表面的原理实现与实战案例

2026-09-06 18:02:48作者:鲍丁臣Ursa

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 拼接处的瑕疵)。

实现原理可类比相机投影:以 positionorientation 定义一个"贴花投影仪"(projector),以 size 定义其投射体积(一个轴对齐的长方体),然后把目标网格中落在该体积内的部分"剥"下来,生成一个自带 positionuvnormal 属性的新 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 的双重作用值得注意:

  1. 裁剪体积:在 clipGeometry 中半轴长度 s = 0.5 * Math.abs( size.dot( plane ) ),即六个裁剪平面与投影仪中心相距 size/2,构成一个 size 大小的长方体投影视锥;
  2. UV 映射:最终 UV 按 0.5 + x / size.x0.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 属性不存在则视为空几何直接返回。

每取出一个顶点,都通过 pushDecalVertexL189-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 ) - ss 为半轴长 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 );
}

三个要点:

  1. UV 在投影仪空间中计算:以投影仪正前方为中心,纹理 UV 的 (0.5, 0.5) 对准投影仪中心,size 决定 UV 的缩放密度;
  2. 顶点再乘正向 projectorMatrix 变回世界空间,写入 position 属性;
  3. 法线属性是可选的:只有当目标网格提供了 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 作为投影仪的 orientationL258-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.pngdecal-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 ) 对应移除。

六、已知限制与注意事项

综合官方文档与源码,实际使用时请注意:

  1. 拐角处失真:官方文档明确提示 "decal projections can be distorted when used around corners"(文档中同时引用了 wolfire 博客 How to project decals 的投影原理及 three.js 关于无失真贴花投影的 issue 讨论)。原因是六平面体积裁剪是"体"级别的操作,无法处理同一贴花横跨多个不同朝向面的转折。
  2. 几何是静态的:目标网格的变形(morph、物理模拟等)不会反映到已生成的贴花上;需要重新构造。
  3. 目标网格需带法线才有光照normal 属性是可选输出,用光照材质时请确保源网格的 geometry.attributes.normal 存在且 matrixWorld 已更新。
  4. size 同时控制裁剪范围与 UV 密度:想让贴图更"密"时不要只调材质,应调大 size(UV 按 1/size 缩放)。
  5. 空几何安全:非索引且无 position 属性的几何会被静默跳过,生成空几何(L126),不会抛错。

七、小结与相关文件

DecalGeometry 以约 420 行代码实现了一套完整的"投影仪体积裁剪"算法:顶点经 matrixWorldprojectorMatrixInverse 双变换进入投影仪空间,经六个轴对齐平面裁剪出体积内的三角面,再按 size 归一化生成 UV 并回写世界空间,最终输出带 position/uv/(可选)normal 的非索引 BufferGeometry。它与 Raycaster 命中点、面法线、MeshPhongMaterial 的透明/深度/偏移配置组合,就能实现游戏中常见的弹孔、泼溅、贴纸等效果。

本文涉及的仓库文件:

(本文基于当前仓库版本 0.185.0 的源码与文档编写。)

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391