GRAPHICS × PRODUCTION
中EN

皮了个牛

↓

工程实践

商业化材质体系:效果表达与工程优化

文章从上线前 iOS 纹理绑定超限导致的 8% 崩溃率切入,追溯商业化角色材质在纹理数量、ALU、Sub-Mesh 和 Draw Call 上的膨胀,并以移动端 GPU 的纹理延迟、寄存器 Occupancy、分支分化和 Metal 31 槽硬墙建立底层约束。随后记录用 RenderDoc 与 LLM 逆向竞品染色 Shader、修复暗部 Artifact,并将 HSV 相关计算移到 CPU 的过程;提出四层 CommercialMaster、Texture-8 通道预算与寄生复用策略,覆盖 CPU/GPU 预计算、循环展开、静态分支、选择算子、Mask 分块裁剪、材质槽清理、双面拆分和妆容烘焙。验证数据显示指令数、VGPR 和纹理内存显著下降,同时保留 PC 端 0.27ms 增量与 Texture-8 后续扩展受限等边界。

2025-12-1327 分钟阅读
商业化材质体系:效果表达与工程优化
AI 生成概念封面

上线前大规模 QA 暴露出的第一个严重问题是 iOS 崩溃率 8%。排查堆栈,绝大部分指向同一个位置:纹理绑定超限。Metal 的硬墙是 31 个纹理绑定槽,而角色渲染走到妆容、皮肤、装备、特效叠完一整套流程后,实际采样数已经远超这条线。系统并不会给你一个温柔的降级,iOS 上超限就是直接闪退。

崩溃只是最显眼的症状。往回追一层,问题的根在美术资产的膨胀方式上。

一个角色的材质渲染需要 15 张以上的纹理采样。皮肤一套、妆容一套、头发一套、眼睛一套、装备若干套,每一套都带着自己的 BaseColor、Normal、Mask、Detail 贴图。这些纹理并不共享,因为它们各自服务于不同的可定制维度:换妆、换发色、换瞳色,每个维度都需要独立的参数控制通道。15 张是日常,复杂角色还会更多。

纹理数量往上走,ALU 也跟着爆。为了支持运行时调色(让玩家自由选择发色、唇色、腮红色),Shader 里大量使用 RGB↔HSV 转换。单个角色渲染一帧,RGB→HSV→RGB 的往返做了 28 次,Gamma 校正做了 14 次。这些都不是轻量运算:一次 RGB↔HSV 包含分支、除法、取模,展开后指令数相当可观。最终单角色的 Shader 指令数达到 1300 条以上,这个数字在移动端是非常重的负担。

场景层面的开销也不乐观。Draw Call 峰值超过 5000,Overdraw 倍率达到 2.5x。每一次 Draw Call 意味着一次 CPU-GPU 状态同步,5000+ 次调用叠加上频繁的纹理绑定切换、Shader 变体切换,状态切换开销占到帧时间的 15%。Overdraw 2.5x 意味着屏幕上每个像素平均被着色 2.5 次,大量算力浪费在最终被遮挡的片元上。

这几个数字放在一起看,不是某个单点的问题。纹理多导致绑定超限和带宽压力,调色逻辑复杂导致 ALU 爆炸,资产碎片化导致 Draw Call 居高不下,Overdraw 导致着色浪费。它们互相咬合,构成一个系统性的工程困境。做效果表达的人总想多加一层,做性能优化的人总想少算一点,两边的诉求在既有架构里没有调和的余地。

我们需要一套材质体系层面的重新设计:在不砍效果表达能力的前提下,把纹理数量、ALU 复杂度、Draw Call 规模同时拉到移动端可承受的范围内。

一、已有做法的问题

重构之前的材质体系在纹理数量、ALU 复杂度和资产组织三个方向上同时超标。

1.1 纹理与 ALU 膨胀

先看纹理。15 张以上的采样数不是谁拍脑袋定的,而是功能需求自然累加的结果。皮肤需要 BaseColor + Normal + Roughness/AO,妆容需要独立的 Mask + Color 贴图,头发需要各向异性高光的 FlowMap + Shift 贴图,眼睛需要虹膜 + 瞳孔 + 焦散贴图,装备还有自己的 PBR 三件套。每个系统独立演化,互相之间没有共享机制,贴图数量就这么一路加上去了。

ALU 的膨胀有一条清晰的因果链。运行时调色是商业化的核心需求,玩家付费购买的皮肤必须支持自定义颜色。实现调色最直接的方式是 RGB↔HSV 转换:把颜色搬到 HSV 空间,偏移 Hue,再转回 RGB。问题是这条路径的计算量不轻。一次 RGB→HSV 包含条件分支(判断最大分量)、一次除法、一次取模;HSV→RGB 又是一组分支和乘加。一个角色身上有多个可调色区域,发色、唇色、腮红、眼影、瞳色,每个区域都要走一遍这个流程。28 次 RGB↔HSV 转换、14 次 Gamma 校正,最终把单角色 Shader 推到 1300+ 条指令。

1300 条指令在桌面端不算什么,但移动端 GPU 的 ALU 吞吐量比桌面低一到两个数量级。更关键的是,高指令数意味着高寄存器占用,高寄存器占用意味着低 Occupancy,低 Occupancy 意味着延迟隐藏能力下降。这条链在第二章展开。

Draw Call 5000+ 的来源也值得拆解。角色身上的装备、挂件、特效各自独立提交绘制命令,场景里的建筑、植被、地面、天空也各管各的。没有系统级的合批策略,Draw Call 数量就完全取决于美术拆分粒度。Overdraw 2.5x 则和半透明材质的大量使用、全局双面渲染有直接关系,后者单独就贡献了额外 30% 的 Overdraw。

1.2 资产利用效率

材质和纹理的膨胀只是显性问题。隐性问题同样严重:大量资产并没有被高效使用。

Sub-Mesh 碎片化是第一个典型。角色模型在建模阶段被拆成大量细碎的 Sub-Mesh:脸部单独一块、眉毛单独一块、睫毛单独一块、上衣袖子和衣身分开、鞋带和鞋面分开。每个 Sub-Mesh 绑定独立的材质实例,哪怕两个 Sub-Mesh 的材质参数完全一致,引擎也会把它们当作两次 Draw Call 提交。碎片化的 Sub-Mesh 不仅推高了 Draw Call 数量,也让 GPU 的顶点处理单元频繁切换状态,打断流水线。

全局双面渲染是第二个典型。为了简化美术制作流程,大量模型被标记为双面渲染,头发、布料、树叶、草地、飘带,甚至一些完全不需要双面的硬表面装备也被误标。双面意味着背面三角形也要参与光栅化和着色,这直接给 Overdraw 加了 30% 的常量开销。而实际上,真正需要双面的只有薄片状几何体(头发丝、单层布片),其余都可以用法线翻转或单面+厚度偏移来处理。

妆容系统是第三个典型。妆容由多个图层叠加合成:底妆、腮红、眼影、眼线、眉毛、唇彩、高光、修容……一套完整妆容最多达到 13 层叠加。每一层都有独立的 Mask 贴图和 Color 参数,渲染时依次采样、混合。13 层叠加意味着 13 次纹理采样和 13 次 Alpha 混合,这个数字本身就够吃掉相当一部分纹理采样预算了,更不用说每一层还可能附带调色逻辑。

这三个问题有一个共同特征:它们都不是技术限制导致的,而是生产流程缺少约束导致的。Sub-Mesh 拆分没有合批规范,双面标记没有审核机制,妆容层数没有性能预算。美术在自己的 DCC 工具里看到的是效果,看不到运行时代价。等资产进入引擎跑起来,性能问题已经固化在资产里了。

二、GPU 底层约束

材质体系的设计不能只看上层逻辑,必须贴着硬件特性走。移动端 GPU 和桌面端差异显著,很多在桌面端"没关系"的做法到移动端就变成了性能陷阱。

2.1 纹理采样的延迟与隐藏

纹理采样是一次显存访问操作,延迟通常在几百个时钟周期量级。GPU 隐藏这种延迟的核心机制是 warp 切换:当一个 warp(通常 32 个线程为一组)发起纹理请求并等待数据返回时,调度器把这个 warp 挂起,切换到另一个已经准备好的 warp 继续执行。只要有足够多的 warp 可以切换,ALU 就不会因为等数据而空转。

这个机制的效率可以用一个简单的公式描述:

η ≈ 1 − (N / T)

其中 N 是单次纹理采样的延迟周期数,T 是所有可调度 warp 提供的总计算周期数。T 取决于同时驻留在 SM(Streaming Multiprocessor)上的 warp 数量,也就是 Occupancy。

以实际数据代入:优化前单角色 Shader 采样 15 张纹理,Occupancy 低。η ≈ 1 − (15/64) ≈ 0.765,也就是 ALU 有约 23.5% 的时间在等数据。

优化后纹理采样降到 8 张,寄存器压力减轻,Occupancy 提升。η ≈ 1 − (8/64) = 0.875,空闲等待从 23.5% 降到 12.5%。

这就是为什么减少纹理采样的收益是非线性的:它不仅省了采样本身的开销,还通过降低寄存器压力→提升 Occupancy→增大 T 值,间接提升了整个 Shader 的执行效率。

2.2 寄存器压力与 Occupancy

移动端 GPU 每个计算单元的寄存器文件是固定大小的。一个 Shader 占用的寄存器(VGPR,Vector General Purpose Register)越多,同时能驻留的 warp 就越少,Occupancy 就越低。

优化前的角色 Shader 因为要处理大量调色逻辑(28 次 RGB↔HSV 转换、14 次 Gamma 校正)和 15+ 张纹理的采样结果,编译器需要分配 64 个以上的 VGPR 来保存中间结果。64+ VGPR 在典型的移动端 GPU(如 Mali 或 Adreno)上直接把 Occupancy 压到 18-20%。

18-20% 的 Occupancy 意味着 SM 上最多只能同时驻留不到总容量五分之一的 warp。一旦当前 warp 遇到任何延迟事件(纹理采样、显存读取、流水线气泡),调度器很快就无 warp 可切,ALU 被迫空转。这就是上面 η 公式里采样次数占比偏高的根本原因。

优化后通过重构调色算法(用查找表替代 RGB↔HSV 运算)、压缩纹理通道(多张贴图 pack 到更少的纹理里)、简化分支逻辑,VGPR 占用下降到合理范围,Occupancy 回到 50% 以上。这不只是一个数字的改善,从 20% 到 50%,可调度 warp 数量增加了 1.5 倍以上,延迟隐藏能力的提升是根本性的。

2.3 分支分化

GPU 的 SIMT(Single Instruction Multiple Threads)执行模型有一个天然弱点:同一个 warp 内的所有线程必须执行相同的指令。当 Shader 代码里出现 if-else 分支,且同一个 warp 内不同线程走了不同分支时,两条路径都要执行,走 true 分支时 false 分支的线程被 mask 掉闲置,反过来也一样。这就是 warp divergence。

在角色材质 Shader 里,分支分化是一个普遍问题。不同的材质类型(皮肤、头发、眼睛、布料)走不同的着色路径,同一个 Draw Call 里如果混合了多种材质类型,divergence 就不可避免。调色逻辑里判断"当前像素属于哪个可调色区域"也会引入分支。在物体明暗交界区域(如阴影边缘,高亮和阴影像素交错),测量到的分化率达到 70%,由此导致的 pipeline bubble 大约占帧时间的 10%。

解决分支分化有两条路。第一条是减少分支本身:把条件判断替换为数学等价的无分支写法。比如 if (mask > 0.5) color = A; else color = B; 可以改写为 color = lerp(B, A, step(0.5, mask));,编译器会将其翻译为 movc(conditional move)指令,不产生分支。第二条是减少同一 warp 内走不同分支的概率:通过材质分类排序,让同一材质类型的片元尽量落在同一个 warp 内,降低 divergence 的发生频率。

实际操作中两条路都要走。能用 movc / step / lerp 替代的条件分支全部替换,不能替代的(比如完全不同的着色模型切换)通过分类绘制来隔离。

2.4 移动端绑定数硬墙

Metal API 对单个 Fragment Shader 可绑定的纹理数量有明确上限:31。这不是一个可以通过参数调整绕过去的软限制。超过 31 个绑定,API 调用直接失败,在 iOS 上表现为应用崩溃。

这就是那 8% 崩溃率的直接技术原因。

31 这个数字看起来不少,但在实际渲染管线里消耗得很快。角色材质 15+ 张、场景 IBL 贴图(Irradiance + Prefiltered + BRDF LUT)3 张、Shadow Map 至少 1 张、后处理相关的 Buffer 若干张。如果 Shader 还需要访问全局纹理(比如噪声图、查找表),31 个槽位很容易就不够用。

Vulkan 和 OpenGL ES 的情况稍好但本质相同,都有绑定数上限,只是具体数字不同。

我们在设计材质体系时定下的 Texture-8 标准(单个材质最多使用 8 张纹理)就源于这个硬约束。31 个槽位要分配给整条渲染管线的所有环节,留给单个材质的预算不能超过一个安全阈值。8 张是在保证系统级纹理(阴影、IBL、全局 LUT 等)有足够槽位的前提下,留给材质的最大配额。

从 15 张降到 8 张,不是简单地砍掉 7 张贴图。这要求重新设计纹理的通道布局,把原本分散在多张贴图里的信息 pack 到更少的纹理中,利用 RGBA 四通道的每一个分量。也要求重新设计调色方案,不能再为每个可调色区域单独准备 Mask 贴图,而是用通道编码把多个区域的 Mask 压缩进一张纹理。

从崩溃率 8% 到零崩溃,从 15+ 纹理到 8 张以内,从 Occupancy 18-20% 到 50%+,从 1300+ 指令到精简后的规模。材质体系的设计必须把 GPU 的物理约束当作第一性输入,而不是最后才对付的性能瓶颈。

三、AI 辅助 Shader 逆向与优化

搞清楚硬件约束之后,下一步是确定染色算法本身该长什么样。

染色系统的第一个难题不是"怎么写",而是"写成什么样"。竞品的染色效果已经在线上跑了两年,玩家对色彩过渡的预期被它锚定了,我们需要先搞清楚它到底做了什么,再决定自己怎么做。

3.1 RenderDoc + LLM 逆向竞品染色 Shader

RenderDoc 截帧拿到的竞品染色 Shader 有大约 500 行混淆 GLSL。变量名全是 _u_xlat0 到 _u_xlat47,控制流被编译器内联展开过,嵌套三四层 mix 和 step,人眼直接读基本没有语义。

传统逆向思路是手工重命名、逐行标注,一个资深 TA 大概需要两到三天。我换了个做法:把整段 GLSL 丢给 ChatGPT,提示词写明"这是一段移动端染色 Shader 的反编译输出,请识别其中的色彩空间转换、色调映射和渐变混合逻辑,用伪代码重新描述核心算法"。

第一轮输出就识别出了三个关键结构:RGB → HSV 转换、V 通道的 Gamma 重映射、HSV → RGB 回转后的线性混合。但细节有偏差,它把一段 smoothstep 保护误读成了 Tone Mapping 算子,把循环内的渐变采样当成了 LUT 查表。经过大约 15 轮迭代修正,核心算法链条清晰了:

  1. 输入 Base Color → RGB 转 HSV
  2. H 通道做色相偏移(目标色替换)
  3. S 通道做饱和度缩放
  4. V 通道做 Gamma 重映射(pow(V, gamma)),控制明暗对比
  5. HSV 转回 RGB,与原色按 Mask 做渐变混合

识别出的算法链条要落地成 HLSL。RGB 和 HSV 的互转函数是基础件:

float3 RGBToHSV(float3 rgb)
{
    float cMax = max(rgb.r, max(rgb.g, rgb.b));
    float cMin = min(rgb.r, min(rgb.g, rgb.b));
    float delta = cMax - cMin;

    float h = 0.0;
    if (delta > 1e-5)
    {
        if (cMax == rgb.r)
            h = fmod((rgb.g - rgb.b) / delta, 6.0);
        else if (cMax == rgb.g)
            h = (rgb.b - rgb.r) / delta + 2.0;
        else
            h = (rgb.r - rgb.g) / delta + 4.0;
    }
    h /= 6.0;
    if (h < 0.0) h += 1.0;

    float s = (cMax > 1e-5) ? (delta / cMax) : 0.0;
    float v = cMax;
    return float3(h, s, v);
}

float3 HSVToRGB(float3 hsv)
{
    float h = hsv.x * 6.0;
    float s = hsv.y;
    float v = hsv.z;
    float c = v * s;
    float x = c * (1.0 - abs(fmod(h, 2.0) - 1.0));
    float m = v - c;

    float3 rgb;
    if      (h < 1.0) rgb = float3(c, x, 0);
    else if (h < 2.0) rgb = float3(x, c, 0);
    else if (h < 3.0) rgb = float3(0, c, x);
    else if (h < 4.0) rgb = float3(0, x, c);
    else if (h < 5.0) rgb = float3(x, 0, c);
    else              rgb = float3(c, 0, x);
    return rgb + m;
}

这两个函数在逆向出的算法里反复出现,后面所有染色逻辑都建立在它们之上。

3.2 用 AI 工具链验证和修复 Artifact

逆向出来的算法直接写成 Shader 一跑,伪影发生率大约 25%。最典型的问题是暗部死黑块,深色布料的褶皱处出现硬切边的纯黑色块,像被挖掉一样。

根源在 pow(V, gamma) 这个操作。当 V 趋近 0 时,幂函数的斜率骤增。gamma 值如果大于 1(压暗操作),pow(0.01, 2.2) 的结果是 0.0000631,几乎就是纯黑;而 pow(0.02, 2.2) 是 0.000282,差了四倍多。这意味着暗部极小的输入差异被指数级放大,量化到 8-bit 帧缓冲后全部坍缩成 0。

第一版修复用 SafeGamma 做下限保护:

float SafeGamma(float x, float gamma)
{
    float safeX = max(x, 0.001);
    return pow(safeX, gamma);
}

这解决了纯黑问题,但引入了新的伪影,暗部出现一条可见的亮度断层,因为 0.001 这个硬阈值在梯度上制造了不连续点。

真正的问题比 pow 本身更深一层。幂函数做亮度调整,隐含假设是"物理亮度的等比变化等于感知亮度的等比变化"。但人眼感知遵循韦伯-费希纳定律,感知亮度大致和物理亮度的对数成正比。暗部微小的物理亮度差异,在感知上的权重远大于亮部。幂函数在暗部的指数级压缩,直接违反了这个感知特性。

解决方案是用 Sigmoid 曲线替代幂函数。Sigmoid 在两端自然饱和,中间区域近似线性,暗部不会被压到零,亮部不会过曝:

float SigmoidRemap(float x, float midpoint, float slope)
{
    return 1.0 / (1.0 + exp(-slope * (x - midpoint)));
}

midpoint 控制中间调的位置,slope 控制对比度强度。和 pow 不同,Sigmoid 在 x→0 时的导数趋近于 0 而不是趋近于无穷,暗部过渡天然平滑。

再配合 smoothstep 做边界保护,覆盖 Mask 边缘的渐变区域:

float ApplyGammaRemap(float v, float gamma, float midpoint, float slope,
                       float edgeMin, float edgeMax)
{
    float sigV = SigmoidRemap(v, midpoint, slope);
    float safeV = SafeGamma(v, gamma);
    float blend = smoothstep(edgeMin, edgeMax, v);
    return lerp(sigV, safeV, blend);
}

暗部走 Sigmoid 路径,中亮部走 SafeGamma 路径,smoothstep 负责在两者之间做无缝过渡。修复后伪影发生率从 25% 降到不足 1%。

3.3 最终染色模型

逆向、修复、验证走完,染色管线的最终形态做了一次关键的架构决策:把 HSV 转换从 Shader 里挪到 CPU 端预计算。

运行时 Shader 不再做 RGB→HSV→修改→HSV→RGB 的完整链条。美术在编辑器中调好目标色后,CPU 端直接把目标色转成 HSV,计算好 H 偏移量、S 缩放系数、V 的 Sigmoid 参数,打包成 Uniform 传进去。Shader 只需要处理 V 通道的渐变混合:

// 最终染色核心循环
float3 ApplyDyeColor(float3 baseColor, float3 targetHSV,
                      float mask, float gradient,
                      float sigmoidMid, float sigmoidSlope,
                      float gamma, float edgeMin, float edgeMax)
{
    float3 baseHSV = RGBToHSV(baseColor);

    // H/S 直接替换(CPU 已预计算目标值)
    float3 dyedHSV;
    dyedHSV.x = targetHSV.x;
    dyedHSV.y = targetHSV.y;

    // V 通道:Sigmoid + SafeGamma 混合渐变
    float remappedV = ApplyGammaRemap(baseHSV.z, gamma,
                                       sigmoidMid, sigmoidSlope,
                                       edgeMin, edgeMax);
    dyedHSV.z = lerp(baseHSV.z, remappedV * targetHSV.z, gradient);

    float3 dyedRGB = HSVToRGB(dyedHSV);
    return lerp(baseColor, dyedRGB, mask);
}

mask 控制哪些区域参与染色,gradient 控制渐变强度。整个函数只有一次 RGB→HSV、一次 HSV→RGB、一次 ApplyGammaRemap 和两次 lerp。

最终编译结果:457 条指令。作为对比,逆向出的竞品原始实现编译后超过 1300 条指令。指令数砍掉了接近三分之二,核心原因有三个:HSV 转换只做一次而不是逐通道分别处理;Sigmoid 参数由 CPU 预计算而非 Shader 内动态求解;幂函数和 Sigmoid 的混合比例用 smoothstep 一次算完,不需要分支判断。

四、CommercialMaster 统一材质框架

染色算法解决了单个效果模块的问题,但角色材质不只有染色。一个赛季上线二三十套皮肤,每套皮肤可能有布料、金属、皮革、丝绸四五种材质,叠加染色、发光、流光等特效的排列组合,Shader 变体数量会指数爆炸。运行时切换变体意味着 PSO 编译和显存占用,美术制作时在十几个材质球之间反复切换意味着认知负担和出错概率。

CommercialMaster 的目标是一个材质球覆盖商业化皮肤的全部需求。

4.1 四层架构

材质框架分四层,每一层职责明确、向上屏蔽实现细节:

Layer 0 是 Base PBR,标准的金属度/粗糙度工作流,处理漫反射、高光、法线。这一层和引擎默认 PBR 完全一致,不做任何定制。

Layer 1 是 Mask Atlas,把所有材质区域的遮罩打包进 Atlas 纹理。一个 RGBA 四通道 Mask 可以划分四个独立区域(比如上衣主体、袖口金属件、领口皮革、内衬布料),每个区域可以独立控制染色、特效参数。两张 Mask 纹理就能覆盖 8 个区域,远超单套皮肤的实际需求。

Layer 2 是 Effect Blocks。染色、发光、流光、丝绸光泽等特效以"功能块"的形式存在,每个功能块有独立的开关和参数。美术在面板上勾选需要的功能块,不需要的块在编译期直接裁剪掉,不会产生任何运行时开销。

Layer 3 是 Performance Gate,性能预算层。根据目标机型档位(高/中/低),自动降级特效复杂度:高档全开,中档关闭流光的二次采样,低档回退到简化染色模型。降级策略写在材质配置里,不需要美术手动处理。

4.2 Texture-8 标准

八张贴图是整个框架的资源预算硬约束。分配方案:

编号命名通道分配用途
Tex1_3UBCARGB: Base Color, A: AO基础颜色+环境遮蔽
Tex2_MixR: Metallic, G: Roughness, B: Emission Mask, A: Cloth MaskPBR 参数混合
Tex3_NRG: Normal (BC5), BA: 未使用法线贴图
Tex4_Mask01RGBA: 四区域染色遮罩染色区域划分
Tex5_Mask02_GradRG: 额外两区域遮罩, B: 渐变图, A: 预留染色扩展+渐变
Tex6Effect Atlas 1特效专用流光方向/噪声
Tex7Effect Atlas 2特效专用发光图案/动画
Tex8Effect Atlas 3特效专用备用特效通道

前三张是基础 PBR,美术闭着眼睛都会配。中间两张是染色系统专用,承载区域遮罩和渐变信息。后三张留给特效,根据实际需求选用。

八张的硬约束看起来苛刻,但实际生产中大部分皮肤只用到五到六张。约束的价值在于消灭了"再加一张贴图"的决策成本,不需要评审、不需要和技术确认显存预算,八张以内美术自由发挥。

4.3 寄生策略

有些特效的视觉效果很抢眼,但底层数据和已有通道高度重叠。丝绸光泽(Shine)就是典型案例。

Silk 材质的 Mask 已经标记了哪些区域是丝绸,Shine 特效需要的信息恰好也是"哪些区域需要高光增强"。把 Shine 的触发条件绑定到 Silk Mask 上,只要 Silk Mask 值大于阈值就激活 Shine 计算,不需要额外的贴图采样,也不需要独立的 Mask 通道。

这种"寄生"策略在框架内系统性地使用:凡是触发条件能从已有数据推导的特效,都不分配独立资源。实际效果是,Shine 特效的边际采样开销为零。

变体数量的最终结果:相比之前每种材质类型×每种特效组合各一个 Shader 的做法,CommercialMaster 的变体数减少了 90%。一个材质球,静态分支控制功能块开关,美术只面对一套参数面板。

五、性能调优

统一材质框架把功能收拢到一个 Shader 里,代价是更重的单次 Draw Call 开销。调优目标很具体:中端机维持 30fps 稳定,不出现材质相关的掉帧尖刺。

5.1 CPU/GPU 协同预计算

染色系统有大量参数组合运算,色相偏移矩阵、饱和度缩放矩阵、亮度 Sigmoid 参数。这些参数在一帧内不会变化(由美术配置决定,不随顶点或像素变化),没有理由在 GPU 端逐像素重复计算。

把这些运算搬到 CPU 端,每帧只算一次,结果以 Uniform 形式传入。具体收益:免除了 28 次矩阵乘(每次 HSV 变换涉及多步向量运算,6 个染色区域各做一次),GPU 端 ALU 指令减少约 20%。代价是 CPU 端多了一点 Uniform 更新开销,实测增加约 5%,这个交换在移动端非常划算,因为大多数商业化场景下 CPU 是轻载的,瓶颈在 GPU 的像素填充率。

5.2 关闭循环展开

HLSL 编译器默认会尝试展开循环。对于染色系统的区域循环(遍历 4~6 个染色区域,每个区域做一次完整的染色计算),展开意味着把循环体复制四到六份。

直觉上展开应该更快,减少了分支判断和循环计数开销。但实际情况更复杂。展开后的指令数翻了数倍,占用更多 I-cache,移动 GPU 的 I-cache 通常很小,膨胀后的 Shader 可能导致缓存颠簸。展开后所有迭代的中间变量同时活跃,寄存器 live-range 拉长,寄存器溢出到本地内存的代价远高于循环本身。而且染色区域数量由 Uniform 控制,每帧固定不变,这种 uniform 循环的分支预测几乎不会失败,分支开销接近于零。

加上 [loop] 属性显式禁止展开:

[loop]
for (int i = 0; i < _DyeRegionCount; i++)
{
    // 每个区域的染色计算
    ...
}

实测在 Adreno 和 Mali 上都有正向收益,尤其是区域数量多的皮肤。

5.3 静态分支

CommercialMaster 的功能块开关(染色开/关、流光开/关、Shine 开/关)用两种策略处理。

对于不同机型档位的差异(低档机不需要流光),用 #pragma shader_feature 做编译期 Variant 裁剪。低档变体里流光相关的代码完全不存在,不占指令槽。

对于同一档位内的功能开关(美术决定这件皮肤不用 Shine),用 Uniform 变量做运行时分支:

if (_EnableShine > 0.5)
{
    // Shine 计算
    ...
}

GPU 的 SIMD 执行模型下,如果一个 Warp 内所有像素的 _EnableShine 值相同(Uniform 分支天然满足这个条件),那么未命中的分支体直接跳过,不会有"两条路径都执行"的问题。Uniform Branch 的运行时开销几乎为零,但避免了为每个功能组合编译独立变体。

5.4 选择算子

部分逻辑看起来需要分支,但实际上是二选一赋值:

// 分支写法
if (mask > 0.5)
    result = valueA;
else
    result = valueB;

// 选择算子写法
result = (mask > 0.5) ? valueA : valueB;

三目运算符在大多数 GPU 编译器下会生成 movc(条件移动)指令,而不是真正的分支。movc 是纯 ALU 操作,没有分支预测、没有 Warp 分歧开销。在染色系统里,Mask 边界判断、渐变方向选择等场景大量使用这个模式,用 movc 替代 lerp 在语义明确为二选一的场景下更高效,lerp 会做一次乘法和一次加法,movc 只做一次条件移动。

5.5 Mask 分块动态裁剪

染色计算的开销和参与计算的像素数量成正比。一件皮肤的实际染色区域通常不会覆盖整个模型,腰带扣、鞋底、头发这些区域的 Mask 值通常为 0,不需要染色。

利用 GPU 的 Warp 执行特性:如果一个 Warp(通常 32 或 64 个像素)内所有像素的 Mask 值都为 0,那么整个 Warp 可以跳过染色计算。这就是 Warp 同质性,当一个 Warp 内的条件分支结果完全一致时,另一条路径的指令不会被执行。

大面积 Mask=0 的区域(比如角色的裤子在"只染上衣"的配色方案下)会产生大量全零 Warp,染色分支被整体跳过。实际在中端机上测试,Mask 裁剪带来 15% 到 20% 的染色 Pass 耗时缩减,具体数字取决于皮肤设计中染色区域占总面积的比例。

配合前面的 [loop] 属性,最内层染色循环的结构是:

[loop]
for (int i = 0; i < _DyeRegionCount; i++)
{
    float regionMask = SampleRegionMask(uv, i);
    // Warp 同质性:大面积 mask=0 的 Warp 跳过整个循环体
    if (regionMask < 0.001)
        continue;

    // 完整的染色计算
    float3 dyedColor = ApplyDyeColor(baseColor, _TargetHSV[i],
                                      regionMask, gradient,
                                      _SigmoidMid[i], _SigmoidSlope[i],
                                      _Gamma[i], _EdgeMin[i], _EdgeMax[i]);
    baseColor = dyedColor;
}

这段代码把前面所有优化串联起来:CPU 预计算的参数通过 Uniform 数组传入,[loop] 防止编译器展开,regionMask 判断利用 Warp 同质性做动态裁剪,ApplyDyeColor 内部用 Sigmoid + SafeGamma 混合确保无伪影。457 条指令、一个材质球、覆盖全部商业化需求,这是染色系统最终交付的状态。

六、资产侧优化

Shader 层面的改造只解决了渲染管线内部的问题。但实际线上环境里,大量性能浪费发生在管线之外,资产本身的组织方式。

6.1 材质槽清理与双面渲染拆分

角色资产经过多个版本迭代,普遍存在一个隐蔽的冗余:同一材质被分配到多个材质槽。原因很简单,不同部位的 Sub-Mesh 在制作时由不同美术负责,各自创建了独立材质实例,尽管参数完全相同。这些冗余槽直接转化为额外的 Draw Call,GPU 并不知道两个槽里装的是同一个东西,它只认绑定状态的切换。

清理策略分两步走。第一步是合并同参数材质槽,把散落在多个 Sub-Mesh 上的相同材质收归为单一引用。第二步是将小面积附件(扣子、铆钉、挂饰等)的独立 Sub-Mesh 合入相邻主体 Mesh。两步下来,单角色的 Draw Call 数量有明显下降。

双面渲染的处理逻辑类似。旧方案里,部分角色整件衣服都开着双面渲染,理由是"反正也不差这点性能"。但双面渲染意味着背面三角形同样走光栅化和片元着色,对 Overdraw 的贡献很直接。实际需要双面的区域只有披风下摆、裙摆飘带这类会露出背面的薄片结构,大约占总面积的 5% 左右。

把双面渲染的范围限制在这 5% 面积之后,其余部分回到 Cull Back,整体 Overdraw 下降约 30%。在中端设备上,这个改动单独贡献了约 1ms 的帧时间回收。代价几乎为零,只需要美术在资产导出时标记哪些 Sub-Mesh 需要双面,工具链自动拆分。

6.2 妆容烘焙管线

妆容系统的性能问题也出在同一个地方:功能正确但代价过高。

原始方案支持 13 层妆容实时叠加:底妆、腮红、眉形、眼影、眼线、睫毛、唇色……每一层都是独立纹理,在片元着色器里依次采样、混合。这意味着每个片元要做 13 次纹理采样和 13 次混合运算。功能上没有问题,玩家确实能看到精细的妆容效果。但 13 层的采样和混合开销被每个片元重复执行,而妆容在一帧之内根本不会变化。

解决思路是把实时计算搬到离线。当玩家调整完妆容参数并确认后,后台启动一次烘焙流程:在 Compute Shader 里把 13 层妆容按照相同的混合规则合成为一张最终贴图,写回到角色的妆容纹理槽。之后渲染时,片元着色器只需要采样这一张合成结果,13 次采样 + 混合变成 1 次。

烘焙过程在后台异步执行,耗时在几十毫秒量级,玩家在正常游戏流程中完全无感。如果玩家重新调整了妆容,系统重新触发一次烘焙,替换合成贴图即可。

这个改动带来的直接收益是:有妆容和无妆容的角色在渲染性能上几乎没有差别。旧方案里"带满妆容的角色比素颜角色贵 13 倍采样"的问题被彻底消除。对于多角色同屏场景,这个差值会被人数放大,烘焙方案的收益更为显著。

七、验证数据

以下数据基于同一角色、相同画面配置下的前后对比。

Shader 指令数从 1198 降到 303,缩减 75%。指令数的大幅缩减主要来自 Uber Shader 拆分、分支消除和妆容烘焙的综合效果。全效果染色 Shader 指令数从 1300 降到 457,缩减 65%,保留了 Sigmoid 色彩映射和暗部补偿的额外 ALU。VGPR 占用从 192 降到 90,缩减 53%,寄存器压力的下降直接改善了 Warp 占用率,对中端 Adreno / Mali 的实际吞吐影响比指令数更大。纹理内存从 60 MB 降到 40 MB / 角色,来自通道重新排布和冗余贴图合并。

纹理绑定数降低约 70%,单材质控制在 8 以内。旧方案在部分低端设备上因为单次绘制绑定超限导致崩溃,限制到 8 之后此类崩溃归零。主流中高端设备上稳定 60fps,多角色同屏不再出现旧版本的帧率跳水。

PC 侧染色开销新方案 1.12ms vs 旧方案 0.85ms,增加了 0.27ms(PC 模拟测试,染色材质单 Pass 耗时)。这个增量来自 Sigmoid 映射和暗部处理引入的额外计算,在 PC 端换来的是深色区域不再发灰、色相偏移可控。

八、已知限制

PC 侧染色管线的开销增加是一个明确的 trade-off。Sigmoid 色彩映射函数本身需要指数运算(虽然用了近似),暗部补偿逻辑也增加了若干条 ALU 指令。在移动端,这些增量被 Uber Shader 拆分和妆容烘焙带来的大幅缩减所覆盖,净结果仍然是正向的。但在 PC 端,旧 Shader 本身并不存在移动端那些严重的冗余,所以染色路径的额外开销没有被对冲掉,体现为 0.27ms 的净增量。对于 PC 来说这个数字可以接受,但它确实存在。

Texture-8 的硬约束解决了当前的兼容性问题,同时也框定了未来的天花板。单材质 8 张纹理的限制在现有效果集下是够用的,但如果后续要加入更复杂的材质效果,比如各向异性高光需要额外的切线贴图、次表面散射需要厚度图,每多一张贴图就要考虑从现有 8 张里挤出一个槽位,或者走通道压缩把新数据塞进已有贴图的空闲通道。当可压缩的空间用尽时,这个标准本身需要重新评估。

AI 辅助流程是另一个需要诚实面对的点。目前 AI 在材质制作中的角色是生成候选方案供美术挑选和调整,它能提高探索效率,但产出质量高度依赖 prompt 的描述精度和美术人员的判断力。prompt 写得含糊,生成结果就不可控;美术对材质物理特性理解不足,也难以从候选方案中选出合理的那个。它是一个加速工具,不是自动化管线。这条边界在实际使用中比预期更明显。

写在最后

染色系统的 AI 辅助逆向是一个意外收获。原本只是想省掉人工读混淆 GLSL 的时间,做下来发现 LLM 在"从反编译输出里还原算法结构"这件事上确实有效率优势,但后续的 Artifact 修复和参数调优仍然高度依赖对 GPU 硬件行为的理解。AI 加速了前半程,后半程还是得靠人。

Texture-8 的余量迟早会被吃完。资产规模还在涨,效果需求还在加,单材质 8 张纹理的限制在现有效果集下是够用的,但各向异性高光需要额外的切线贴图、次表面散射需要厚度图,每多一张贴图就要考虑从现有 8 张里挤出一个槽位,或者走通道压缩把新数据塞进已有贴图的空闲通道。当可压缩的空间用尽时,这个标准本身需要重新评估。