TiXL SplitMeshVertices 算子详解:网格顶点拆分实现 Flat Shading 与平滑几何散射
【免费下载链接】t3TiXL is an open source software to create realtime motion graphics.项目地址: https://gitcode.com/GitHub_Trending/t3/t3
SplitMeshVertices 是 TiXL(tooll3)算子库Lib.mesh.modify中负责"顶点拆分"的网格修改算子:它把索引化(Indexed)网格中每个三角形面片引用的顶点展开为三个相互独立的顶点,从而解除面片之间共享顶点的关系,为 Flat Shading(平直着色)、逐面片散射(Scatter)等操作扫清障碍。本文将以官方文档 SplitMeshVertices.md 为骨架,结合其 C# 算子定义、节点图定义与 HLSL 计算着色器源码,讲透它的参数含义、底层实现原理与典型实战用法,帮助你直接在 VJ / 实时动态图形项目中控制网格的平滑度。
一、算子定位:为什么要"拆分顶点"
在现代 GPU 渲染中,三角形网格通常以"索引化"方式存储:顶点缓冲(Vertex Buffer)存放去重的顶点,索引缓冲(Index Buffer)用三个索引(int3)描述一个三角形面片。这种结构天然支持平滑着色(Smooth Shading)——相邻面片共享顶点,共享顶点的法线是多个面片法线的平均值,光照过渡柔和。
但当你想做以下事情时,共享顶点就成了阻碍:
- 散射(Scatter)平滑着色的几何体:例如把每个面片沿各自方向"炸开",如果面片仍共享顶点,顶点会被多个面片拉扯,无法独立移动;
- 把着色改成 Flat(平直):每个面片需要拥有自己独立的法线,而不是共享插值后的法线;
- 需要按面片为单位独立处理顶点属性(法线、切线、副切线)。
SplitMeshVertices 的官方文档一句话点明了它的用途:This can be relevant to "scatter" smooth shaded geometry or change shading to flat.(详见 Lib.mesh.modify 算子目录 中对本算子的描述)。它把"每个面片 3 个顶点"这一关系显式化,让后续的逐面片操作成为可能。
二、输入输出参数速览
按照文档中给出的参数表,该算子只有两个输入和一个输出:
| 参数(类型 / 关联性) | 说明 |
|---|---|
InputMesh(MeshBuffers,Required) | 输入的网格缓冲。MeshBuffers封装了顶点缓冲、索引缓冲与块定义缓冲(详见下文第五节),必需连接,否则算子无数据可处理 |
ShadeFlat(Single,即float) | 控制着色平直程度的系数,取值范围本质上是 0~1 的插值因子,但也可以传入大于 1 的值以强化效果 |
输出:
| 输出 | 类型 |
|---|---|
| Result | T3.Core.DataTypes.MeshBuffers |
关于 ShadeFlat 的关键细节
- 默认值 0:从 SplitMeshVertices.t3 的
DefaultValue可以看出,ShadeFlat默认是0.0。此时输出顶点完全保留原始法线,网格外观基本不变; 0~1:介于两者之间时,每个顶点的法线在"原始法线"与"面片平均法线"之间线性插值(lerp),即从平滑着色向平直着色过渡;1:完全平直着色,同一面片的三个顶点法线收敛为同一个面片平均法线;- 大于
1:在官方示例 ScatterMeshFacesExample.t3 中,ShadeFlat被设为1.57,说明可以超过 1 使用,以在散射场景中进一步强化面片感(HLSL 的lerp并不会限制系数范围)。
关于 InputMesh 与 Relevancy
在 SplitMeshVertices.t3ui 的InputUis中,InputMesh的Relevancy被标记为Required,ShadeFlat则为可选(未标记)。也就是说在 TiXL 编辑器中,InputMesh端口会以"必需连接"的方式呈现,未连接时算子不会产生有效输出。
三、源码实现剖析:从 C# 插槽到计算着色器
3.1 C# 算子定义
SplitMeshVertices.cs 定义了算子的骨架——两个输入插槽和一个输出插槽:
namespace Lib.mesh.modify; [Guid("3f0f0c40-482d-4d79-a201-b4651a0cd3a8")] internal sealed class SplitMeshVertices : Instance<SplitMeshVertices> { [Output(Guid = "7873a4c0-04ff-41b5-bf5e-66ae745c3918")] public readonly Slot<MeshBuffers> Result = new(); [Input(Guid = "22370faa-8381-4878-8653-2fe9297400da")] public readonly InputSlot<MeshBuffers> InputMesh = new(); [Input(Guid = "308f12dc-a308-472a-923e-20f0a20d54db")] public readonly InputSlot<float> ShadeFlat = new(); ... }可见其输入输出类型严格对应文档:InputMesh接收MeshBuffers,ShadeFlat接收float,Result输出MeshBuffers。类中还定义了Spaces(PointSpace / ObjectSpace / WorldSpace)与Directions(WorldSpace / SurfaceNormal)两组枚举,为后续扩展(例如按指定空间与方向计算拆分)预留了空间——目前算子主体逻辑由节点图内的计算着色器完成。
3.2 内部节点图:GPU 流水线
SplitMeshVertices 本质上是一个由多个基础算子组成的Symbol(符号/节点图),其图定义见 SplitMeshVertices.t3,界面布局见 SplitMeshVertices.t3ui。从图中可以还原出完整的 GPU 处理流水线:
_MeshBufferComponents:把输入的MeshBuffers拆解为顶点缓冲、索引缓冲等组成部分;GetBufferComponents× 4:分别取出源索引缓冲与源顶点缓冲的 SRV(着色器资源视图)及其尺寸属性;ComputeShader:加载着色器源码Lib:shaders/3d/mesh/mesh-SplitVertices.hlsl(即仓库中的 mesh-SplitVertices.hlsl);GetSRVProperties+CalcDispatchCount:根据索引缓冲大小计算需要的线程调度数量,并送入ComputeShaderStage的Dispatch(图内预设为64, 1, 1,IntValue节点固定为 64,对应着色器[numthreads(64,1,1)]的线程组大小);MultiplyInt(B=3):因为每个面片拆分后要生成3 个独立顶点,所以新顶点数量 = 面片数 × 3;NewVertices/NewFaceIndices:申请新的顶点缓冲(Stride为 32,即一个PbrVertex的大小)与新的索引缓冲(Stride为 12,即一个int3);FloatsToBuffer:把ShadeFlat标量写入常量缓冲,供着色器读取;ExecuteBufferUpdate+_AssembleMeshBuffers:执行 GPU 计算后,把新顶点缓冲与新索引缓冲重新组装成MeshBuffers,从Result端口输出。
整条链路完全在 GPU 上以 Compute Shader 完成,不需要 CPU 回读数据,因此即使网格面片数量很大,性能开销也主要集中在一次Dispatch上。
3.3 HLSL 核心逻辑:一线程一面片
mesh-SplitVertices.hlsl 是算子真正的"心脏",其关键结构如下:
cbuffer Params : register(b0) { float ShadeFlat; } StructuredBuffer<int3> SourceIndices : register(t0); StructuredBuffer<PbrVertex> SourceVertices : register(t1); RWStructuredBuffer<int3> ResultIndices : register(u0); RWStructuredBuffer<PbrVertex> ResultVertices : register(u1); [numthreads(64,1,1)] void main(uint3 i : SV_DispatchThreadID) { uint numFaces, stride; SourceIndices.GetDimensions(numFaces, stride); int faceIndex = i.x; if(faceIndex >= numFaces) return; int3 indicesForFace = SourceIndices[faceIndex]; int vertexIndex = faceIndex * 3; float3 p1 = SourceVertices[indicesForFace.x].Position; float3 p2 = SourceVertices[indicesForFace.y].Position; float3 p3 = SourceVertices[indicesForFace.z].Position; ... ResultVertices[vertexIndex+0] = SourceVertices[indicesForFace.x]; ResultVertices[vertexIndex+1] = SourceVertices[indicesForFace.y]; ResultVertices[vertexIndex+2] = SourceVertices[indicesForFace.z]; float3 n1 = SourceVertices[indicesForFace.x].Normal; float3 n2 = SourceVertices[indicesForFace.y].Normal; float3 n3 = SourceVertices[indicesForFace.z].Normal; float3 n = normalize(n1 * w.x + n2 * w.y + n3 * w.z); ResultVertices[vertexIndex+0].Normal = lerp(n1, n, flat); ResultVertices[vertexIndex+1].Normal = lerp(n2, n, flat); ResultVertices[vertexIndex+2].Normal = lerp(n3, n, flat); ... ResultIndices[faceIndex] = int3(vertexIndex, vertexIndex+1, vertexIndex+2); }实现要点:
- 并行策略:每个 GPU 线程处理一个面片(
faceIndex = i.x),线程数由面片数决定,超出部分直接return;源索引以int3形式按面片存储(每面片 3 个索引); - 顶点展开:面片
f的三个源顶点被复制到目标顶点缓冲的f*3、f*3+1、f*3+2位置,新索引缓冲则直接写入(f*3, f*3+1, f*3+2)——这就是"拆分"的本质:相同的顶点数据被复制多份,面片之间不再共享; - 法线处理:先对三个顶点法线做加权平均得到面片法线
n(w为权重向量,着色器中通过对三条边向量做点积来探测退化/共线边,把对应权重置 0,避免退化面片污染平均结果),再用lerp把每个顶点法线从原始值向面片法线过渡,过渡量就是ShadeFlat; - 切线/副切线同步处理:
Bitangent与Tangent使用同样的加权平均 +lerp逻辑,保证在法线贴图(Normal Mapping)场景下,着色空间依然自洽; - 顶点位置、UV 等其余属性则原样复制,不做任何修改。
需要说明的是,这里的"面片法线"是三个顶点法线的加权平均(近似面片法线),而不是用叉积重新计算的几何法线;从源码结构看,它旨在用最轻量的方式抹平顶点法线的"平滑过渡",而非做严格的几何法线重算。若需要基于几何形状重算法线,可与同组的 RecomputeNormals 文档 所述算子配合使用。
四、实战用法:配合 ScatterMeshFaces 做面片散射
SplitMeshVertices 最常见的实战场景是与ScatterMeshFaces搭配使用。官方示例 ScatterMeshFacesExample.t3 中同时出现了这两个算子:ScatterMeshFaces(见 ScatterMeshFaces 文档)负责按面片中心把网格"炸开"并保持单个面片尺寸不变,随后SplitMeshVertices以ShadeFlat = 1.57的参数接入,把散射后的网格进一步拆分为独立顶点并强制平直着色。
典型的节点链如下:
Mesh 源 → ScatterMeshFaces → SplitMeshVertices(ShadeFlat) → 渲染为什么需要这样的组合?
ScatterMeshFaces会把每个面片沿面法线方向推开。如果网格仍是索引化、共享顶点的结构,推开的动作会受到相邻面片共享顶点的牵制,产生拉伸或撕裂;- 先用
SplitMeshVertices把顶点按面片拆开(此时ShadeFlat=0可保持外观不变),每个面片就拥有了物理上独立的顶点; - 再配合
ShadeFlat=1(或示例中的1.57)把法线统一为面片法线,散射后的每个小面片以 Flat 方式独立受光,视觉上"碎片感"极强——这正是 VJ 场景中常见的爆炸/解体/像素化碎裂效果的核心构成。
同样的思路也适用于任何需要"逐面片独立变换"的场合:例如结合DeformMesh、DisplaceMesh等变形类算子时,先拆分顶点可以避免共享顶点被多次位移叠加。同组算子的完整清单与各自说明,可查阅 Lib.mesh.modify 算子目录。
五、输出 MeshBuffers 与下游连接
Result输出的类型是T3.Core.DataTypes.MeshBuffers,其定义见 MeshBuffers.cs:
public class MeshBuffers: IDisposable { public BufferWithViews VertexBuffer = new (); public BufferWithViews IndicesBuffer = new (); public BufferWithViews ChunkDefsBuffer = new (); ... public int FaceCount => IndicesBuffer?.Srv != null ? IndicesBuffer.Srv.Description.Buffer.ElementCount : 0; }- 它把渲染一个网格所需的**顶点缓冲、索引缓冲、块定义缓冲(ChunkDefs)**打包在一起,拆分后的新顶点与新索引正是通过
_AssembleMeshBuffers重新装入这三个缓冲后从Result输出的; - 其
ToString()实现会报告"{vertexCount} vertices {faceCount} faces",在编辑器中悬停即可直观确认拆分效果——拆分后顶点数会变为原来的约 3 倍(具体取决于原网格的共享顶点比例),而面片数不变; - 输出可以继续连接到任何接受
MeshBuffers的渲染/修改算子,例如各类 Mesh 材质渲染算子、TransformMesh、FlipNormals等,整条链仍然保持在 GPU 缓冲层面传递,没有额外的 CPU 拷贝开销。
六、总结
SplitMeshVertices 是一个"小而关键"的网格预处理算子:它通过一次 GPU Compute Shader 调度,把索引化网格展开为面片独立的顶点集合,并以一个ShadeFlat系数在平滑着色与平直着色之间自由过渡。无论是想对平滑几何体做面片散射、制作碎裂解体效果,还是单纯想快速切换 Flat Shading 风格,都可以把它插入到网格修改链中,配合 ScatterMeshFaces、RecomputeNormals 等同组算子获得完整的处理流程。官方示例 ScatterMeshFacesExample.t3 是上手最快的参照,直接打开即可观察ShadeFlat参数对散射网格视觉风格的实时影响。
【免费下载链接】t3TiXL is an open source software to create realtime motion graphics.项目地址: https://gitcode.com/GitHub_Trending/t3/t3
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考