三维与渲染

WebGL2 / ANGLE 着色器编译卡死与长帧阻塞排查

定位 Windows Chrome(WebGL2 → ANGLE → D3D11 fxc)下两类着色器编译退化:循环内动态索引常量数组导致的 144 秒无响应,以及动态光源循环内隐式偏导采样导致的 3 秒链接阻塞。

约 18 分钟读完
目录
  1. 1. 运行期切换材质时的 144 秒编译冻结
  2. 无查找表解析噪声与单变体裁剪
  3. 2. 标准 PBR 动态循环内的隐式偏导与 linkProgram 阻塞
  4. 3. 构建钩子依赖与 .shaderbundle 缓存边界
  5. 4. 冷缓存实测结果与错峰预热边界

在基于 Flutter Web(3.47.9)与 flutter_scene(0.24.3)搭建本站三维书房时,主力测试环境是 Windows 11 下的 Chrome,底层图形链路走的是 WebGL2 -> ANGLE -> D3D11 HLSL (fxc)。在带本地编译缓存的日常开发浏览器里,场景切换和帧率看起来都比较正常;但换用全新的无缓存浏览器配置(--user-data-dir 临时目录或开启 ignoreCache)做冷启动实测时,先后暴露出两处严重的着色器编译阻塞:

  1. 首次在运行期点击某件交互道具、将表面玻璃材质动态替换为自定义程序化材质(.fmat)时,浏览器主线程与 GPU 同步停滞了 144,393.2 ms(约 2 分 24 秒),直接触发浏览器的“页面无响应”弹窗;但只要等这一帧编译过去,后续立刻恢复 60 fps。
  2. 冷缓存首次加载书房首屏时,即便模型和贴图已经下载解码完毕,主线程仍然出现 7 次超过 1.5 s 的长任务冻结,其中单次 gl.linkProgram 同步阻塞达 3,151 ms。

这两处问题表象不同,但追到底层都和 ANGLE 将 GLSL ES 3.00 翻译为 HLSL 后交给 D3D11 fxc 编译时的退化行为有关。

1. 运行期切换材质时的 144 秒编译冻结

起初怀疑是切换材质时创建了过大的纹理或陷入了 Dart 侧死循环,但在 requestAnimationFrame 帧间隔与浏览器性能面板里核对调用栈后,发现耗时几乎全部停在单帧渲染提交内部的原生 WebGL 管线创建上。

在 flutter_scene / flutter_gpu 的渲染流程中,材质管线采用惰性实例化策略:只有当第一个挂载了该材质的可见 RenderItem 真正进入 RenderPass 绘制时,引擎才会在当前帧同步调用 compileShader 与 linkProgram。

直接拆检构建产物 *.shaderbundle 里展开后的 GLSL 源码,才看清这枚程序化材质为什么会导致 fxc 编译耗时达到分钟级:

  1. 自发光特效误用了完整的 lit 光照模型:材质原本只需要输出程序化云团与边缘微高光,但声明了 shading_model: lit。编译器因此自动拼入了整套 3,000 余行的级联阴影、Froxel 点光源与 LTC 面光源 PBR 框架,并为静态网格和变形网格生成双份变体。
  2. 多层循环内联展开了带常量查找表的三维噪声:片元着色器引入了内置 <noise.glsl> 的 NoiseSimplex3,并在多步体积采样与分形布朗运动(FBM)循环中高频调用。内联展开后,单文件体积膨胀到 669 KB(双变体合计约 13,780 行 GLSL),包含 29 个嵌套 for 循环、512 个 if 分支,以及 181 次对 const float[256] 排列表字面数组的动态下标寻址。

在桌面 OpenGL 或 Vulkan(SPIR-V)驱动上,这类常量表寻址通常能较快完成编译;但在 Windows Chrome 的 D3D11 后端,ANGLE 会把 GLSL 翻译成 HLSL 源码再调用旧版 D3DCompile(fxc)。当嵌套循环内部存在大量对大型字面常量数组的动态索引时,fxc 在做循环展开与可索引临时寄存器(x0[])分配时会出现超二次方的编译耗时激增。

无查找表解析噪声与单变体裁剪

定位到这一层之后,修改方向就明确了——核心不是把编译提前,而是先移除触发 fxc 退化的语法结构:

  1. 将自发光程序化特效材质改为 shading_model: unlit,剥离全部阴影与多光源采样器,只生成单一管线变体;原本依赖光照模型提供的表面掠射微高光,直接用 reflect(-V, N) 做几行解析近似。
  2. 移除 <noise.glsl>,改写为零查找表、零分支、零内部循环的双 highp vec4 多项式哈希三维平滑值噪声,并用一次 4 阶旋转级联同时算出低频云团(macro)与高频絮状细节(tufts),避免重复采样:
material {
  name: "PortalVortex",
  shading_model: unlit,
  blending: opaque,
  culling: back,
  parameters: [
    { type: float, name: time, default: 0.0 },
    { type: float, name: intro, hint: range(0.0, 1.0, 0.01), default: 1.0 },
  ],
}

fragment {
  const highp mat3 kOctaveRot = mat3(
     0.00,  0.80,  0.60,
    -0.80,  0.36, -0.48,
    -0.60, -0.48,  0.64
  );

  // 无查找表、无分支的四路多项式哈希三维 C2 平滑值噪声
  float smoothNoise3(highp vec3 p) {
    highp vec3 x = p * 1.48;
    highp vec3 i = floor(x);
    highp vec3 f = x - i;
    highp vec3 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);

    highp vec2 px = fract(vec2(i.x, i.x + 1.0) * 0.1031);
    highp vec2 py = fract(vec2(i.y, i.y + 1.0) * 0.1030);
    highp vec2 pz = fract(vec2(i.z, i.z + 1.0) * 0.0973);

    highp vec4 ax = px.xyxy;
    highp vec4 ay = py.xxyy;

    highp vec4 az0 = vec4(pz.x);
    highp vec4 d0 = ax * (ay + 33.33) + ay * (az0 + 33.33) + az0 * (ax + 33.33);
    highp vec4 h0 = fract((ax + ay + 2.0 * d0) * (az0 + d0));

    highp vec4 az1 = vec4(pz.y);
    highp vec4 d1 = ax * (ay + 33.33) + ay * (az1 + 33.33) + az1 * (ax + 33.33);
    highp vec4 h1 = fract((ax + ay + 2.0 * d1) * (az1 + d1));

    highp vec4 hz = mix(h0, h1, u.z);
    highp vec2 hxy = mix(hz.xz, hz.yw, u.x);
    float val = mix(hxy.x, hxy.y, u.y);
    return clamp(0.5 + (val - 0.5) * 0.88, 0.0, 1.0);
  }

  vec2 sampleCloudCascade(highp vec3 p) {
    float n0 = smoothNoise3(p);
    highp vec3 p1 = kOctaveRot * (p * 2.08 + vec3(1.7, -2.3, 1.1));
    float n1 = smoothNoise3(p1);
    highp vec3 p2 = kOctaveRot * (p1 * 2.08 + vec3(-2.1, 1.4, 0.9));
    float n2 = smoothNoise3(p2);
    highp vec3 p3 = kOctaveRot * (p2 * 2.10 + vec3(1.2, -1.8, -1.5));
    float n3 = smoothNoise3(p3);
    return vec2(
      clamp(0.56 * n0 + 0.30 * n1 + 0.14 * n2, 0.0, 1.0),
      clamp(0.48 * n1 + 0.34 * n2 + 0.18 * n3, 0.0, 1.0)
    );
  }
}

改完后重新检查 .shaderbundle,该材质展开后的体积从 669 KB 降到 35.7 KB(共 426 行,字面数组数量为 0),冷编译耗时本身已经从 144.4 s 降到了几百毫秒以内。

为了同时消除首次点击切换时的管线链接停顿,又在实木背板后方被遮挡的位置放置了一枚 0.6 mm 的微型预热面片:首屏前 2 帧随场景完成一次管线创建,第 3 帧自动将 visible 置为 false,稳态运行期不产生额外绘制调用。这里需要注意先后顺序——假设不修改着色器实现、只增加首屏预热面片,只是将 144 秒的阻塞从点击道具时转移到了进入页面的第一帧,问题并没有解决。

2. 标准 PBR 动态循环内的隐式偏导与 linkProgram 阻塞

解决了自定义特效材质之后,在全新 Chrome 配置下拦截计时 WebGL2RenderingContext.prototype.linkProgram,发现场景首屏仍然有两次长达 3,151 ms 的同步链接阻塞(分别对应标准 PBR 材质的普通网格变体与形态键网格变体,合计卡住主线程逾 6 s)。

排查 flutter_scene 默认的 StandardFragment 光照实现后发现,着色器在遍历当前像素所属 Froxel 格子的动态上限点光源循环(for (int i = 0; i < count; ++i))以及条件级联阴影分支内部,使用了常规的 texture(...) 采样 punctual_lights、punctual_index、shadow_map 与分层环境辐射度贴图。

在 GLSL 中,texture(...) 默认依赖相邻 2×2 像素块的隐式屏幕空间偏导数(dFdx / dFdy)来自动推导 Mipmap 层级;而转换到 HLSL Shader Model 5 后,位于非均匀控制流(动态循环上限或依赖运行时数据的条件分支)内部的隐式梯度 .Sample() 是非法指令。ANGLE 的 HLSL 翻译器与后端 fxc 为了合法化这段代码,不得不重写控制流并强行展开循环,导致单次 linkProgram 耗时超过 3 秒。

实际上,阴影深度图集、Froxel 光源参数表(punctual_lights / punctual_index)、SSAO 缓冲与 BRDF LUT 本身都是单层数据纹理,根本不存在 Mipmap 链,完全不需要硬件计算偏导数。

因此在不动上游包源码的前提下,为 Web 端单独提供了一份保持相同 Uniform 块和采样器绑定布局的自定义 PBR 片元着色器包(study_web_pbr.frag),把所有位于循环或条件分支内的辅助缓冲采样统一改为显式零偏导的 textureLod(..., 0.0),同时裁掉当前书房未使用的盒状反射探针与双环境贴图混合分支:

highp vec4 FetchPunctualTexel(int light_index, int col) {
  highp vec2 uv = vec2((float(col) + 0.5) * 0.125,
                       (float(light_index) + 0.5) / frag_info.punctual_dims.x);
  return textureLod(punctual_lights, uv, 0.0);
}

highp vec4 FetchPunctualEntry(int j) {
  highp float width = frag_info.punctual_dims.y;
  highp float fj = float(j);
  highp vec2 uv = vec2((mod(fj, width) + 0.5) / width,
                       (floor(fj / width) + 0.5) / frag_info.punctual_dims.z);
  return textureLod(punctual_index, uv, 0.0);
}

float WebSpotShadowTap(highp vec2 uv, highp float tile, highp float total,
                       highp float receiver) {
  highp vec2 atlas_uv = vec2((tile + clamp(uv.x, 0.0, 1.0)) / total,
                             1.0 - clamp(uv.y, 0.0, 1.0));
  return receiver <= textureLod(shadow_map, atlas_uv, 0.0).r ? 1.0 : 0.0;
}

这里有一条不能越界的边界:textureLod(..., 0.0) 只用于阴影图、光源数据表、SSAO 与 LUT 这类辅助缓冲;材质自身的 base_color_texture、normal_texture 和 metallic_roughness_texture 采样处于循环外的均匀控制流中,必须保留常规 texture(...),否则远景木地板和织物会失去各向异性过滤与 Mipmap 抗锯齿,产生明显的闪烁和摩尔纹。同时还需要保留 AlphaMode.mask 的 alpha_cutoff 丢弃分支,否则书架绿植叶片与镂空贴图会退化成实心方块。

3. 构建钩子依赖与 .shaderbundle 缓存边界

接入自定义着色器包的过程里,遇到了两个容易让人误判优化无效的工程细节:

第一,在 hook/build.dart 中调用 buildTargetShaderBundleJson 编译自定义着色器包时,构建钩子默认只把 .shaderbundle.json 清单文件本身纳入增量构建依赖,不会自动扫描清单内部引用的 .frag 或 .vert 源文件。如果不显式注册依赖,修改 .frag 后执行 flutter build web 会直接复用旧的编译产物:

output.dependencies.addAll([
  input.packageRoot.resolve('shaders/study_web_pbr.shaderbundle.json'),
  input.packageRoot.resolve('shaders/study_web_pbr.frag'),
  input.packageRoot.resolve('shaders/study_web_pbr_cube.frag'),
]);
await buildTargetShaderBundleJson(
  buildInput: input,
  buildOutput: output,
  manifestFileName: 'shaders/study_web_pbr.shaderbundle.json',
  includeDirectories: [input.packageRoot.resolve('shaders/')],
  glesLanguageVersion: 300,
);

第二,静态站部署到 Cloudflare Pages 时需要对构建产物做差异化缓存配置。flutter_scene 导出的三维模型二进制(*.fsceneb)文件名带有内容哈希,适合配置一年期 immutable 强缓存;但编译生成的 opengl_es/*.shaderbundle 文件名携带的是引擎版本标识(engineIdentity()),而非着色器源码的内容哈希。假设把整个 flutter_scene_generated/ 目录统一设为 immutable,线上更新着色器但未升级引擎版本时,此前访问过的浏览器就会一直命中旧的 .shaderbundle。因此在 _headers 中需要把这两类构建产物分开配置:

# 着色器包按引擎标识命名而非源码哈希,每次发版需协商校验
/_apps/scene/assets/flutter_scene_generated/opengl_es/*.shaderbundle
  Cache-Control: public, max-age=0, must-revalidate

# 带内容哈希的三维模型二进制,可安全开启长期强缓存
/_apps/scene/assets/flutter_scene_generated/*.fsceneb
  Cache-Control: public, max-age=31536000, immutable

4. 冷缓存实测结果与错峰预热边界

配合首屏将非静态阴影管线(形态键窗帘、折射玻璃与后期通道)分摊到 ready 消息前的 4 帧内错峰预热,在全新临时目录启动的冷缓存 Chrome 下复测,主要指标变化如下:

检查项 处理前(冷缓存基线) 处理后(冷缓存实测)
程序化特效材质展开体积 669 KB(双变体,float[256] 查找表) 35.7 KB(unlit 单变体,零查找表)
首次点击切换特效材质帧间隔 144,393.2 ms 145.8 ms(普通点击基线 129.2 ms)
单次标准 PBR gl.linkProgram 3,151 ms 502–600 ms
冷启动 > 1,500 ms 主线程冻结次数 7 次(峰值 8,538 ms) 0 次(单段峰值 1,311 ms)

做多帧错峰预热时还有一个容易忽略的副作用:如果场景开启了平行光静态阴影缓存(cacheStaticShadows: true),第 1 帧必须保持所有标记为 shadowStatic 的静态物体(例如桌面上的精装书与家具)处于可见状态,让太阳阴影图在首帧一次性烘焙完整;只能把 shadowStatic == false 的动态节点推迟到第 2~4 帧再开启,否则首帧生成的静态阴影缓存就会漏掉这些被临时隐藏的物体阴影。