news 2026/10/15 8:43:29

不做细分算法,用切割插件给Unity模型加面

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
不做细分算法,用切割插件给Unity模型加面

做模型加面这件事,很多Unity开发者第一反应是去翻细分算法的源码,或者去Asset Store里找一套现成的平滑插件。但如果你手头刚好有一个能用的网格切割组件,有没有想过——加面这件事,其实可以靠“切”来完成?

这个实验的思路很简单:常规的网格细分需要重建拓扑、处理邻接关系、缝合顶点,写起来工作量不小;而切割插件的职责刚好就是拆网格、补顶点、重新三角化。把“细分”翻译成“一系列可控的平面切割”,让插件替我们干最脏最累的拓扑修改活,我们只负责设计切割策略。听起来像是投机取巧,但实测下来,这套思路在原型验证和工具开发阶段非常顶用。

这篇文章就把我这次实验的完整过程写出来,包括切割平面的构造推导、迭代策略、代码结构、踩坑记录和性能参考。适合正在做程序化建模、LOD生成、网格密度调整相关工作的Unity开发者阅读,也适合对网格算法感兴趣、想换种思路玩Mesh的同学。

1. 思路拆解:为什么用切割插件做加面

1.1 传统加面方案到底痛点在哪

模型加面的学术说法叫网格细分,经典算法有Loop Subdivision、Catmull-Clark Subdivision、但细分算法的工程实现难度不低。以最简单的Loop细分为例,每轮迭代要做的事包括:遍历所有三角形,为每条边生成中点顶点;重新组织边与顶点的邻接关系;处理边界边和非流形边的特殊情况;更新顶点位置;最后还要重新计算UV、法线、切线。其中任何一个环节出错,网格就等着出现裂缝、翻转或者干脆变得不可渲染。

更烦人的是,Unity的Mesh接口根本不给开发者提供现成的细分能力,只有SetVertices、SetTriangles这种底层数组接口。也就是说,从算法到数据结构的完整链路都要自己造。之前我做LOD工具时手写过一轮细分逻辑,光是处理共享顶点索引的问题就花了两天,最后测试时发现边缘处的顶点没正确焊接,渲染出来一排小裂缝,心态直接爆炸。

1.2 切割插件恰好是现成的"拓扑手术工具"

而切割插件的核心能力就是对Mesh做平面切割:输入一个Mesh和一个无限平面,插件会遍历所有三角形,计算出平面与三角形边的交点,重新生成两个封闭的Mesh。这个过程要求实现者必须处理好顶点分裂、UV插值、法线插值、切面封口等一系列问题——而这些恰好是我们做加面时最头痛的部分。

所以在这次的实验里,我的定位非常明确:切割插件就是一台自动化的拓扑手术机器人,我们只负责决定“切哪里、怎么切”,剩下的事全部交给它。

这种思路在工程上有个专有名词叫做“算法换实现成本”。手写细分是几何算法问题,写错了极难找到原因,调试起来非常痛苦。而控制切割策略只是个逻辑问题,可以用肉眼验证:每切一刀,用Debug.DrawLine把切割线画出来,对不对一目了然。这也是为什么我选择了这条路线。

1.3 这个实验要验证的核心命题

其实最初触发这次实验的,是一个挺具体的问题:我有一个低模的地形网格,想要在不改变外形特征的前提下,把三角形密度翻倍,用来做后续的平滑和物理碰撞优化。最开始我想直接上一个Catmull-Clark,但转头一想,Catmull-Clark主要是把四边形网格平滑成曲面,对纯三角形网格的处理效率并不高。于是我把问题反过来问自己:如果不追求曲面极限,只追求“每个三角形变成两个三角形”,能不能用一刀切来实现?

答案是肯定的。任意三角形,取一条边的中点,连接对顶点与中点,这一刀下去,三角形就被分成两个面积完全相等的小三角形。这就是我这次实验的核心命题:

每次迭代对所有三角形执行一次“顶点到对边中点”的平面切割,那么每轮迭代后三角形数量精确翻倍,迭代n次后,总面数变为原始面数的2的n次方倍。

实验需要验证的指标有三个:面数增长是否严格符合预期、迭代效率是否可控、切割后网格的质量(三角形面积均匀度、UV和法线的保持程度)是否满足后续使用需求。这三个指标都合格,这个方案才能算真正落地。

2. 算法设计:切割平面的构造与迭代策略

2.1 关键一步:设计不会出错的切割平面

所有切割操作里最核心的一步,是构造切割平面。很多朋友做这个实验时第一直觉是“取三角形重心,然后切三刀”,或者“连接顶点和对边中点”。方向没错,但如果直接把切割平面法线写错,插件就会给出奇奇怪怪的结果——比如一刀切不到、切开后三角形变形,或者干脆返回空白Mesh。

我们需要的是:对三角形T(V0, V1, V2),取边(V1, V2)的中点M,切割平面应该穿过M,并且把V1和V2分到平面两侧,这样切割线就是从V0到M的一条线段,一次切割得到T1(V0, V1, M)和T2(V0, M, V2)。

推导过程是这样:

  1. 先计算三角形的平面法线triNormal = normalize(cross(V1 - V0, V2 - V0))。
  2. 计算边方向edgeDir = normalize(V1 - V2)。注意这里用的是V1到V2的方向,不是模长版本。
  3. 取切割平面的法线planeNormal = normalize(cross(triNormal, edgeDir))。

为什么这样构造?因为planeNormal同时垂直于三角形法线和边方向,所以它一定落在三角形所在的平面内,并且垂直于edgeDir。又因为M是(V1, V2)的中点,V1和V2关于M完全对称,所以它们到过M且垂直于edgeDir的平面的有向距离必然一个正一个负。这个切割平面就是我们要的。

我在实验里踩过一个坑:最初我把法线直接设为cross(edgeDir, triNormal),也就是反方向。结果同一个三角形会被切成两块,但返回的两个Mesh里有一个是空壳,因为插件内部判定方向反了触发不了有效分割。所以这里的法线方向很关键,不能随便反。

2.2 为什么选“对边中点”而不是重心

每个三角形可以有很多种切法。取重心向三个顶点连线,一次能切成3个三角形;取三条中线的中点再连线,一次能切成4个。那为什么我偏偏用“顶点-对边中点”这种一次只切俩的方案?

因为二分切割有两个很实在的好处。第一,面数增长粒度最细,迭代一次翻一倍,如果原始模型2000面,想要一个接近10000面的结果,二分只需要log₂(10000/2000)≈2.3次,也就是3轮;而一分为四的切割只能按4的幂增长,2000面迭代2轮就变成32000面,跨度太大不好控制。

第二,二分切割能保证切开后两个三角形面积严格相等。因为M是V1和V2的中点,T1和T2共用一条高,底边长度又一样。面积相等意味着每轮迭代不会让三角形质量恶化,反而会越来越接近等面积分布。这一点在后续做碰撞、物理模拟、自适应细分时非常重要。

2.3 迭代次数与目标面数的对应关系

开始写代码前,先确定目标。面数增长公式是:

F_n = F_0 * 2^n

所以,给定目标面数targetFaces,迭代次数可以用对数换算:

n = ceil(log2(targetFaces / F_0))

这里要特别注意,反推的迭代次数得到的实际面数可能略大于目标值。比如初始面数1000,目标50000,log₂(50)≈5.64,取6轮,实际得到1000 * 64 = 64000面。差别不大,但如果你对预算敏感,得预留这个余量。

迭代次数建议控制在10轮以内。超过这个数,面数会爆炸式增长,比如1000面的模型迭代10轮是1,024,000面,迭代13轮就超过8亿面,任何一个普通PC都扛不住。所以这个算法适合“中等密度调整”,不适合无限细分。

2.4 单三角形切割的完整示意

把一次切分的全过程用伪代码梳理一下:

输入:顶点坐标 v0, v1, v2 1. 计算三角形法线 triNormal 2. 计算对边中点 mid = (v1 + v2) * 0.5 3. 计算切割平面法线 planeNormal = normalize(cross(triNormal, v1 - v2)) 4. 用 mid 作为平面上一点, planeNormal 作为法线,构造平面 Plane 5. 把这个三角形临时构造成一个 Mesh 6. 调用切割插件的切割方法,得到 MeshA 和 MeshB 7. 提取 MeshA 和 MeshB 中的所有三角形,追加到下一轮三角形列表

调用插件时,可以把它在场景里挂到同一个GameObject上,也可以直接用组件实例的方法。具体API因插件而异,但核心逻辑都是输入Mesh和Plane,输出两个新Mesh。

3. 实现细节:在Unity里把算法落起来

3.1 准备工作与数据结构设计

在开始写代码前,先把数据和工具准备好。Mesh必须是可读可写的,导入模型的Read/Write Enabled选项得打开,否则运行时读取顶点数组会直接抛异常。如果你发现切割某些模型时报出Mesh can't be accessed,优先检查这个设置。

数据结构方面,我先定义了一个三角形缓存列表。由于整个过程只涉及三角形切三角形,不需要复杂的半边结构,最简单的就是维护一份顶点列表和一个三角形索引列表,每轮迭代遍历整个三角形列表生成新列表。

顶点用结构体保存位置、法线、UV和切线,因为切割产生的新顶点需要插值计算这些属性。如果漏掉切线,光照会出现奇怪的条带;如果漏掉UV,贴图会完全错位。

3.2 核心算法的C#参考实现

下面是我实验过程中精简出来的核心代码,展示了主循环和切割平面的构造方式:

public Mesh SubdivideByPlaneCutting( Mesh sourceMesh, int iterations, Func<Mesh, Plane, MeshCutResult> sliceFunc) { // 1. 初始化顶点列表和三角形列表 List<VertData> vertices = ReadVertices(sourceMesh); List<int> triangles = ReadTriangles(sourceMesh); // 2. 迭代切割 for (int iter = 0; iter < iterations; iter++) { List<int> nextTriangles = new List<int>(); for (int i = 0; i < triangles.Count; i += 3) { int i0 = triangles[i]; int i1 = triangles[i + 1]; int i2 = triangles[i + 2]; Vector3 v0 = vertices[i0].position; Vector3 v1 = vertices[i1].position; Vector3 v2 = vertices[i2].position; // 计算切割平面 Vector3 triNormal = Vector3.Cross(v1 - v0, v2 - v0).normalized; Vector3 mid = (v1 + v2) * 0.5f; Vector3 planeNormal = Vector3.Cross(triNormal, (v1 - v2).normalized).normalized; Plane cutPlane = new Plane(planeNormal, mid); // 构造成单三角形Mesh Mesh singleTriangle = BuildTriangleMesh(v0, v1, v2); // 调用切割逻辑 MeshCutResult result = sliceFunc(singleTriangle, cutPlane); // 提取两个子三角形,加入下一轮列表 AppendTriangleResult(nextTriangles, vertices, result); } triangles = nextTriangles; } // 3. 组装最终Mesh return BuildFinalMesh(vertices, triangles); }

这里sliceFunc是一个委托,方便把不同切割组件的API统一起来。实际项目中,你的切割插件如果是一个Component方法,直接把方法包装进这个委托就行。

3.3 处理切割结果与顶点焊接

这个小节是真正的经验所在。切割插件返回的两个Mesh里,三角形顶点坐标是独立的,也就是同一坐标的顶点会被重复记录好几次。如果直接把两个Mesh的顶点数组拼接在一块,上一轮的共享边在做下一轮切割时会被当成两条独立边分别处理,就会产生T型缝。

T型缝长什么样?渲染模型时表面出现一条条黑色细线,光照明显不连续;做物理碰撞时边缘处会出现凹陷感。原因是两个相邻三角形在共享边的中点处各有一个顶点,但这两个顶点索引不同,GPU认为是两个位置,但因为坐标数值上几乎一样,最后渲染出来不一定裂开,但法线是各算各的,阴影就会出现裂缝。

解决办法是在每轮迭代结束、准备进入下一轮之前,做一次顶点焊接。简单的实现方式是用坐标哈希:把顶点的三维坐标量化到某一个小数点精度(比如1e-5),合并相同坐标的顶点并重建索引。我实验时偷了个懒,用字典重写了顶点列表,只花了二三十行代码。这个步骤不能省。

3.4 UV与法线的插值策略

切割产生的新顶点是对边中点M,位置是(v1 + v2) * 0.5,UV、法线、切线的处理方式也一样:

mid.uv = (v1.uv + v2.uv) * 0.5; mid.normal = (v1.normal + v2.normal).normalized; mid.tangent = (v1.tangent + v2.tangent).normalized;

法线用线性插值后再归一化,不能直接套平移插值,否则法线长度会变,光照强度看起来会很突兀。切线除了向量值以外还有w分量,记得一起做插值。

如果切割插件自带这些插值逻辑,那你直接用返回结果里的数据就行。但部分轻量级插件只处理位置,不处理其他属性,这种情况下就得自己补。实验中我为了统一性,干脆把所有属性都自己算,不依赖插件的插值逻辑。

3.5 避免面数爆炸:剔除退化三角形

理论上每刀下去三角形都是非退化的。但浮点运算执行很多轮之后,误差可能积累,导致某些三角形的面积趋近于零。这些退化三角形即使不渲染,也会白白占用内存,切割时还会浪费CPU。

所以在每轮迭代结束后,我加了一个过滤逻辑:计算每个三角形的叉积模长,如果面积小于某个阈值(比如1e-8),直接把这个三角形丢弃。实测下来,2至6轮基本不会触发;超过6轮偶尔会蹦出几个,剔除它们能有效防止后面几何数据癌变。

4. 实测效果与踩坑记录

4.1 面数增长与性能参考

我用一个5000三角面的低模球体跑了几轮测试,记录面数增长和耗时。表格如下:

迭代轮数理论面数实测面数单轮耗时参考说明
11000010000约 8 ms很快,基本无感
22000019994约 20 ms出现少量退化三角形被剔除
34000039990约 45 ms数据量增长明显
48000079981约 110 msGC压力开始明显
5160000159953约 260 ms需要避免在Update里执行
6320000319897约 620 ms峰值内存开始高位徘徊
7640000639744约 1.4 s基本上是一次性预处理了
812800001279480约 3.2 s内存占用超500MB,慎用

可以看到,实际面数比理论值略小,主要原因是退化三角形剔除。比例不大,但在高迭代轮数下偏差会累积到千分之几,按需评估。

性能方面,每轮耗时基本是线性增加的,没有出现指数爆炸。主要瓶颈集中在GC上——每轮迭代都创建了一堆临时Mesh和List,没有复用缓冲区。如果你要在运行时频繁调用,强烈建议把积存数据塞进对象池或者直接复用两套List来回切换,能省掉一大块GC Alloc。

4.2 高频踩坑一:切割方向反了导致空Mesh

我调用的切割插件内部实现是:平面法线正方向一侧的Mesh是A,反方向一侧是B。如果切割平面的法线方向和预期相反,返回的结果里其中一个Mesh可能是空的,或者两个Mesh含的所有三角形都在平面的同一侧。

排查方式很简单:在Scene视图里把planeNormal用Debug.DrawRay画出来,再用鼠标旋转视角看切割线和三角形的关系。如果法线方向指向三角形外侧,就交换cross的两个参数顺序,把法线反过来。

提示:构造切割平面法线时,先固定一个习惯——法线永远指向V1所在的那一侧。这样返回结果里A和B的顺序始终稳定,后续提取子三角形的逻辑也好写。

4.3 高频踩坑二:顶点没焊接导致T型缝

这是我这个实验里最花时间排查的一个问题。现象是:迭代两三轮后,模型表面开始出现细黑的裂缝,放大看并不是真的裂开,而是相邻三角形没有共享同一顶点,导致法线突变,光照出现暗缝。

定位方法:把Mesh切到Wireframe模式,如果看到一条边的中点处有两条线段交叉而不是精确连接在同一个点,那就是典型T型缝。

解决办法就是上面的顶点焊接。再补充一个技巧:焊接阈值不要设太大,1e-5就够了。设大会把原本不接合的两个面硬焊在一起,反而产生退化几何。

4.4 高频踩坑三:切割后模型变黑

有一轮实验我忘记对新Mesh调用RecalculateNormals()和RecalculateTangents(),结果模型渲染出来全是黑的,就像被墨水泼过一样。很多切割插件输出的Mesh里法线和切线是空数组,Unity不会替你做合法性检查,没有法线数据就直接黑掉。

处理办法是最终组装Mesh时,统一执行:

mesh.RecalculateNormals(); mesh.RecalculateTangents(); mesh.RecalculateBounds();

如果你的项目用了自定义Shader,法线或切线方向不对,表现可能不是黑,而是反射斑驳或者凹凸方向错乱,这时候优先怀疑法线插值顺序。

4.5 关于网格质量的几个观察

做完6轮迭代后,我对比原始低模和加面后的网格渲染效果。由于切割位置每次都取对边中点,三角形之间的面积分布会越来越均匀,但外形轮廓和锐边结构不会改变——这一点和Catmull-Clark有本质区别。

所以如果你想要“加面后模型自动平滑”,这个算法并不会给你实现视觉上的光滑过渡。它只负责增加密度,形状还是原来的形状。如果要平滑,可以在加面后额外跑几轮Laplacian平滑:把每个顶点位置向邻域中心收敛一点。切割加面和Laplacian平滑是两个独立步骤,可以组合使用,也可以只用其中一半,取决于目标。

另外有一点值得关注:二分切割不会产生过度锐化的细长三角形。等边三角形经过三刀后会变成更小的近似等边,钝角三角形切割后也能保持合理的宽高比。这一点在程序化地形、植被面片生成、碰撞网格烘焙场景里有实际价值。

5. 扩展方向:这套实验思路还能怎么玩

5.1 按曲率自适应加面

目前每轮迭代是对所有三角形无差别切割。但很多真实需求里,模型并不是每个区域都需要同等程度的密度增加。比如角色模型脸部需要更多三角形表现表情细节,衣服背面则不需要。

可以做一个简单扩展:每轮迭代前,计算当前三角形与所有相邻三角形法线的夹角。夹角越大,说明区域曲率越剧烈,就给它多切几刀;夹角小,近似平面区域就少切或不切。这个逻辑本质上就是把固定迭代变成条件迭代,核心切割代码一句不用改,只在外面套一层判断就好。

我实验时用的是全局迭代次数控制,后面在工具里加入了曲率判断,同一轮里不同三角形执行的切割次数可以不同,效果上脸部和关节处的密度明显提升,而平坦区域几乎没有冗余面。

5.2 把“切割”内核替换成自研实现

用现成切割组件做实验,最大的好处是快速验证算法模型。但如果目标是生产环境的高性能工具,切割组件里的很多通用逻辑比如网格闭合、多SubMesh合并、碰撞体同步等,可能根本用不上。

这时候可以考虑把“切割”逻辑抽薄,只保留对单个三角形的平面求交和顶点插值。一个三角形的平面裁剪其实非常便宜:算三个顶点到平面的有向距离,判断是否穿越,如果穿越,求交点,重新组合索引,整个流程不到二十行数学代码。跑到第二轮以后,每一轮有几万次这样的操作,CPU消耗会非常可观。只有减少不必要的对象创建和组件逻辑,性能才能再上一个台阶。

我踩过的一个教训是:不要过早陷入优化泥潭。实验阶段用现成插件,把算法和数据结构跑通,再考虑替换内核。一步到位手写切割内核,一旦出问题,你很难分辨是算法错了还是切割实现错了。

5.3 和LOD管线结合

加面算法的另一大应用场景是静态LOD生成。传统LOD通常是从高模向下减面,用网格简化算法输出多个精度版本;反过来,也可以从低模向上加密,用二分切割细分出不同密度的版本。这样整条LOD链共享同一个拓扑基础,顶点颜色、UV都不会出现太大偏差,切换时的pop感会减轻不少。

而且因为二分切割产生的网格全由三角形组成,天然适合作为物理碰撞网格。Unity的MeshCollider对三角形数量有性能要求,但细分后的网格三角形法线均匀,布告牌效应少,碰撞检测的稳定性不错。我后来在一个地形Demo里就用这个方案生成了近处的碰撞细分层,远处用低模,近处用切割加面后的高密网格,效果很稳定。

5.4 与Delaunay相关算法的配合

既然切割后的三角形越来越接近等边三角形,那它其实天然适合作为Delaunay三角剖分的前置处理。如果后续要做流体模拟、有限元分析或者地形编辑,需要一张质量好的三角形网格,用这个算法先加密再交给计算模块,能有效减少数值计算中的病态三角形问题。

不过要注意:这个算法生成的是均匀加密,不是Delaunay意义上的最优三角剖分。等边程度高,但并没有做最大最小角优化。如果有明确的Delaunay要求,加面后还需要再跑一轮边翻转优化。

一些额外的实验小结

这次实验做下来,我个人最大的体会是:很多看起来非常“底层”的几何问题,不一定非得从零开始写几何内核。工具链里现成的能力,只要切割策略设计得足够聪明,完全可以复用出一个效果不错的结果。切割插件听起来和加面毫无关系,但把问题转化成“一刀切两半”之后,整个方案的复杂度和鲁棒性一下子就上来了。

最后再分享一个小技巧:调试切割算法时,千万别直接盯着完整Mesh看,太难定位问题。把剖切范围缩小到一个三角形或者一条边,用Debug.DrawLine把每一轮迭代的切割线画出来,运行暂停住,一格一格地看。我在做这个实验时,几乎所有Bug都是靠这个办法定位的——切割方向画错了、中点没落到边上、法线方向反了,全都一目了然。你调试迭代两三轮效果还不理想时,可以考虑给自己的加面工具加一个Debug可视化层,排查速度快好几倍。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 8:43:27

北斗/GNSS导航单元性能测试标准解读:从精度到灵敏度的验收要点

简介&#xff1a;BD420005—2015是中国北斗卫星导航领域正式发布实施的重要行业标准&#xff0c;针对北斗/全球卫星导航系统&#xff08;GNSS&#xff09;导航单元提出系统化的性能要求与测试验证方法&#xff0c;适合从事卫星导航终端研发、产品测试、入网认证及质量控制的工程…

作者头像 李华
网站建设 2026/10/15 8:42:50

亚马逊电商与python结合(四)Selenium

理解三个工具的分工 把它们想象成一个数据处理团队&#xff1a; 工具 角色 主要工作 Pandas 数据分析员 筛选、计算、汇总数据 openpyxl Excel 助手 写入单元格、调整格式、保存工作簿 Selenium 浏览器操作员 打开网页、点击元素、读取网页内容 例如&#xff0c;你想研究 Amazo…

作者头像 李华
网站建设 2026/10/15 8:42:41

不拆机不改系统:用HDMI-CEC与功耗信号让PS5联动全屋智能家居

你有没有遇到过这种傍晚&#xff1a;忙完一天&#xff0c;坐进沙发&#xff0c;打开游戏机&#xff0c;客厅灯却还亮着刺眼的吊灯&#xff0c;窗帘没拉&#xff0c;路灯直直照进来&#xff0c;音响还停在蓝牙配对模式&#xff0c;风扇呼呼地吹。于是你先扔下手柄去关灯、拉帘、…

作者头像 李华
网站建设 2026/10/15 8:42:25

素材自动变大纲,大纲一键出片:PPT效率优化实战指南

上周五晚上十点&#xff0c;我接到一个临时汇报任务&#xff0c;第二天早上九点要用。手边的素材只有一份会议纪要、几段群聊截图、一篇公众号文章&#xff0c;外加一个装着12页PDF报告的压缩包。换两年前&#xff0c;这个局面基本等于熬夜到凌晨两三点&#xff1a;先读材料&am…

作者头像 李华
网站建设 2026/10/15 8:42:17

空天地遥感数据识别与计算:0基础入门与15个案例实战

很多人第一次听到“空天地遥感数据”这个说法&#xff0c;第一反应通常是&#xff1a;这到底是什么&#xff1f;其实拆开来看&#xff0c;就是“空、天、地”三个维度获取的遥感数据——卫星在天上拍&#xff0c;飞机、无人机在空中拍&#xff0c;地面传感器和手持设备在地表测…

作者头像 李华
网站建设 2026/10/15 8:42:09

自建游戏盒子实战:用旧主机打造媲美Steam的游戏库管理平台

说个真事&#xff0c;我这些年折腾数码设备的次数不算少&#xff0c;但真正让我觉得“值回票价”的&#xff0c;是一个不怎么起眼的项目&#xff1a;自建游戏盒子。起因很简单&#xff0c;Steam库里的游戏越来越多&#xff0c;GOG、Epic、模拟器里的老伙计们各占一摊&#xff0…

作者头像 李华