本文为技术分享,借助官方 LayaAir-MCP 实现。(模型选用 Opus 4.6 搭配 Claude Code)
逛论坛的时候看到有小伙伴寻求关于mesh+shader的按钮特效,遂立即用ai尝试一下,游戏 UI 里常见一种效果:按钮边框上有一道光沿着轮廓跑。看着不复杂,但真动手会发现——圆角怎么拐弯?接缝处怎么不断裂?光怎么绕一圈还能无缝衔接?本项目在 LayaAir 3.4.0 中用纯代码搞定了这件事,没有外部贴图,没有 .shader 文件,全项目程序化。
最终效果:圆角矩形边框上两条子弹形流光交替环绕——前端锐利宽大,尾部拖长渐隐,带模糊消散。颜色速度大小随便调。
拆开来看就两步:先把边框变成一张可以贴纹理的网格,再造一张带子弹形亮斑的纹理让它滚起来。效果如图:

一、把边框变成 Mesh
思路
一根线没有面积,贴不了纹理。所以第一步是把圆角矩形的边框"撑开"成一条有宽度的带子——学名叫三角形条带(triangle strip)。

怎么采样路径
圆角矩形 = 4 段直线 + 4 段圆弧,顺时针走一圈。每段弧细分 16 份,直线段头尾各一个点。每个点除了记位置 (x, y),还要记外法线 (nx, ny)——直线段的法线是边的垂直方向,弧线段的法线就是从圆心指向采样点的方向。

const arc = (cx, cy, a0, a1) => {
for (let i = 0; i <= 16; i++) {
const a = a0 + (a1 - a0) * (i / 16);
path.push({
x: cx + r * Math.cos(a), y: cy + r * Math.sin(a),
nx: Math.cos(a), ny: Math.sin(a) // 法线 = 径向
});
}
};
走一圈下来大约 7080 个点。
撑开成条带
拿到路径点后,每个点沿法线方向外移 borderWidth/2 得到外顶点,内移得到内顶点:
外顶点 = (x + nx * halfBW, y + ny * halfBW)
内顶点 = (x - nx * halfBW, y - ny * halfBW)
然后相邻两对顶点拼成一个四边形,拆成两个三角形——经典的 strip 索引方式。
UV 怎么分配
这是整个方案的关键设计。UV 的两个维度分别映射到两个几何含义上:
- UV.x = 这个点走了多远 / 总周长(归一化弧长,01 刚好绕一圈)
- UV.y = 0 是外侧,1 是内侧
这样一来,纹理的水平方向就是周长方向,后面只要水平滚动纹理,光就沿边框跑起来了。

闭合怎么处理
路径首尾在空间上重合,但 UV.x 不能都是 0——末尾那个点的 UV.x 必须是 1.0。

末点 UV.x=0 会导致最后一段三角形从 0.98 插值到 0.0,等于反着采了一遍纹理,出现亮缝。设成 1.0 后,0.98 到 1.0 插值方向正确,配合 Repeat 模式 1.0 自动等于 0.0,天衣无缝。
二、造一张子弹形流光纹理
不用加载任何图片,直接在内存里逐像素填一张 512 x 32 的 RGBA 纹理。512 像素对应周长方向,32 像素对应边框宽度方向。
两种流光形状
流光亮斑有两种做法:对称高斯和子弹形。对称高斯左右一样宽,像个圆点在跑;子弹形前端锐利集中,尾部拖长消散,更有速度感。

子弹形的实现方式是把一个高斯拆成两半,前后用不同的 sigma:
let d = u - center; // 有符号距离(正 = 尾部方向)
if (d > 0.5) d -= 1;
if (d < -0.5) d += 1;
const sigma = d > 0 ? sigmaTail : sigmaFront;
// ↑ 大,拖长 ↑ 小,锐利
const glow = exp(-d² / (2 * sigma²));
sigmaFront = glowSize × 0.2,锐利截断;sigmaTail = glowSize × 1.2,拉出长尾。同一个高斯公式,左右参数不同,出来的形状就不对称了。
尾部收窄——"子弹头"形状
光有长尾还不够。真正的子弹效果是前端宽、尾部窄,像彗星拖着一条越来越细的尾巴。做法是用一个 taper 系数,让 V 方向(边框宽度)的亮度范围随着离前端的距离线性缩小:
let taper = 1.0;
if (d > 0) {
taper = max(0.15, 1 - d / tailLen); // 越往后越窄,最小 15%
}
edgeFade *= (0.15 + 0.85 * taper); // 宽度跟着 taper 缩
前端 taper=1.0,边框全宽亮起来;尾末 taper=0.15,只剩中心一条细线。配合高斯衰减,形成"前宽后尖"的弹头形状。
尾部消散——"溶解消失"
尾巴不能一直拖,得有个消失的过程。在透明度上再乘一次 taper:
alpha *= (0.3 + 0.7 * taper); // 尾端 alpha 额外降到 30%
叠上高斯衰减本身的透明度下降,尾巴末端几乎全透明——视觉上就是光"溶解"在了暗色边框里。
模糊效果
所谓"模糊",就是光的边缘不要太硬。这件事高斯本身就干了——sigma 越大边缘越柔。尾部 sigma 比前端大 6 倍,自然就模糊得多。再加上 V 方向的二次边缘衰减(中间亮边缘暗),整条流光看起来有体积感,不是一条硬邦邦的亮线。
双流光 + 环绕距离
两条流光分别放在 u=0.25 和 u=0.75,间隔半圈。对每个像素取两条流光中更亮的那个。
距离计算要处理环绕:直接算 |u - c| 在 u 接近 0、c 接近 1 的时候会得出错误的大距离。解决办法是多算两个偏移值取最小:
const d = Math.min(|u - c|, |u - c + 1|, |u - c - 1|);
Repeat 模式
纹理 wrapModeU = Repeat。UV.x 超过 1.0 时 GPU 自动取模,纹理在周长方向无限循环。这一条配置是整个动画能跑起来的前提。
三、让光动起来
纹理造好了,Mesh 也有了,怎么让光跑?
每帧给所有顶点的 UV.x 加一个数。
onUpdate() {
this._time += deltaTime * speed;
const offset = this._time % 1.0;
for (每个顶点 i) {
vertData[i].u = baseU[i] + offset; // 就这一行
}
render.sharedMesh = rebuildMesh(vertData);
}
offset 从 0 涨到 1 再归零,一个循环就是光跑一整圈。两条亮斑间隔 0.5,所以视觉上是交替经过——一条刚过左边,另一条已经到了右边。
speed 传负值?光就反着跑。
重建 Mesh 的开销
每帧改完 UV 后要重新 createMesh2DByPrimitive。听着吓人,其实数据量非常小:80 个点 x 内外 2 个 x stride 5 = 800 个 float。对 GPU 来说这点数据连零头都不算。
试过用 Mesh2DRender 的 tilingOffset 属性直接偏移 UV,省掉重建。但实测不稳定,偏移有时不生效。直接改顶点虽然多一步重建,但行为完全可控,也不费性能。
四、为什么不需要自定义 Shader
做完子弹形、模糊、消散这些效果之后,可能会问:这不就是 Shader 干的事吗?
确实,这些效果用 Shader 也能实现——在片段着色器里接收 u_Time,实时算不对称高斯、taper 收窄、alpha 消散。但这里有个前提:我们的流光图案是静态的。不管时间怎么变,纹理里画的形状永远是那个子弹头。变的只是"纹理贴在 Mesh 的哪个位置"(UV 偏移)。
既然图案不变,就可以提前算好烘进纹理,让 GPU 默认的"查纹理取色"Shader 照搬。这比每帧每像素现场算一遍高斯更省。
什么时候真的需要自定义 Shader?当效果没法提前烘好的时候:
| 需要自定义 Shader | 原因 |
| 流光形状随时间形变(比如呼吸般膨胀收缩) | 每帧图案不同,纹理烘不了 |
| 屏幕空间泛光(Bloom) | 需要额外渲染 pass + 降采样模糊 |
| 噪声扰动(流光边缘抖动) | 需要实时采样噪声纹理做偏移 |
| 背景毛玻璃/折射 | 需要采样屏幕缓冲区 |
我们的效果不在这个表里,所以用纹理就够了——把 Shader 该干的活挪到纹理生成阶段一次性算完,运行时零额外开销。
五、整体流程

六、可调参数一览
| 参数 | 默认值 | 动什么 |
| btnWidth / btnHeight | 500 / 140 | 按钮大小 |
| cornerRadius | 70 | 圆角半径。等于 height/2 就是胶囊形 |
| borderWidth | 8 | 边框粗细。改的是法线偏移量 |
| speed | 0.35 | 流光跑多快。负值反方向 |
| glowSize | 0.18 | 流光占多宽。同时影响前端锐度和尾部长度 |
| glowR/G/B | 255/170/40 | 流光颜色。直接改纹理像素 |
七、总结
整个效果没有用到任何 Shader 文件、外部图片或粒子系统。(当然,使用shader可以做出更多更好的效果)
核心三件事:
- Mesh 构建:沿周长采样、法线撑开、UV 映射弧长——把一根线变成一条可以贴图的带子
- 纹理生成:不对称高斯出弹头形状,taper 收窄出锥形尾巴,alpha 衰减出消散效果——一张 512x32 的纹理装下了所有视觉逻辑
- 动画驱动:每帧 UV 加个数,Repeat 纹理自动环绕——最简单的动画方式驱动最花哨的视觉
最费脑子的是两个不起眼的细节:闭合处 UV.x 必须是 1.0 不是 0(否则接缝处插值反向),环绕距离要三取一最小值(否则跨越 0/1 边界时计算错误)。想明白了都是几行代码的事。
欢迎各位补充讨论
完整源码如下: