三维与渲染

Flutter GPU / Impeller:用颜色化 LOD Probe 排查手工 Mip 链采样

在 Android Vulkan 下遇到手工上传的完整 mip chain 不随 roughness 或 textureLod 变化时,如何通过纯色 LOD 探针、2D 图集控制路径与同版本本地引擎对照定位采样器限制。

约 12 分钟读完
目录
  1. 1. 用颜色化 LOD Probe 替代肉眼观察
  2. 2. 控制路径与修复前后的输出对照
  3. 3. 默认采样器保护与两类纹理契约
  4. 4. 上层渲染库的过渡处理:启动期采样探测与图集回退
  5. 5. 本地引擎验证与跨 GPU 对照边界

在基于 Flutter 3.47(flutter_gpu + Impeller Android Vulkan 后端)调试三维材质的环境反射贴图时,遇到过一个很隐蔽的现象:调用方已经离线预过滤并逐级上传了完整的纹理 mip chain,但在上调材质粗糙度(roughness)或在 Fragment Shader 中显式调用 textureLod 请求更高层级时,画面高光几乎没有扩散,反射始终停留在最锐利的第 0 级效果。

如果在包含场景图、法线贴图、直接光和色调映射的完整画面里仅靠肉眼判断模糊程度,很容易在资产烘焙脚本、Uniform 传递、Shader 分支与具体移动 GPU 驱动之间反复排查。后续通过颜色化 LOD Probe、控制路径对照和同版本本地引擎复核,逐步定位到了底层采样器的最大 LOD 限制。

1. 用颜色化 LOD Probe 替代肉眼观察

在复杂渲染管线里,“反射过锐”属于很弱的观察证据——至少有三种完全不同的环节会产生相似画面:

  1. Dart 侧计算的 roughness 或最大 mip 数未能正确写入 Uniform 缓冲区;
  2. Shader 分支没有走到预期的 textureLod,或者采样坐标异常;
  3. Shader 确实发出了非零 LOD 请求,但 Impeller 底层创建的采样器(Sampler)将 maxLod 限制(clamp)在了第 0 级(base mip)。

为了区分这三种情况,最直接的做法是剥离场景图、光照与资产解码,在最小 flutter_gpu 复现工程中构造颜色化 LOD Probe:不上传真实环境贴图,而是按层级直接写入高对比度纯色像素。

以 4 级手工 mip 链(16×16 → 8×8 → 4×4 → 2×2)为例,Dart 侧逐级填充纯色缓冲区的最小构造逻辑如下:

import 'dart:typed_data';
import 'package:flutter_gpu/gpu.dart' as gpu;

gpu.Texture createColorProbeTexture(gpu.GpuContext context) {
  const baseSize = 16;
  const mipColors = <int>[
    0xFF0000FF, // Level 0 (RGBA little-endian): 纯红 #FF0000
    0xFF00FF00, // Level 1: 纯绿 #00FF00
    0xFFFF0000, // Level 2: 纯蓝 #0000FF
    0xFF00FFFF, // Level 3: 纯黄 #FFFF00
  ];

  final texture = context.createTexture(
    gpu.StorageMode.hostVisible,
    baseSize,
    baseSize,
    format: gpu.PixelFormat.r8g8b8a8UNormInt,
    mipCount: mipColors.length,
    sampleCount: 1,
    enableShaderReadUsage: true,
  );

  for (var level = 0; level < mipColors.length; level++) {
    final dim = baseSize >> level;
    final pixels = Uint32List(dim * dim)..fillRange(0, dim * dim, mipColors[level]);
    texture.overwrite(
      ByteData.sublistView(pixels),
      mipLevel: level,
    );
  }
  return texture;
}

(若复现对象是 Cubemap 环境贴图,则保持同一组层级纯色,按 6 个面逐面写入每一个 mipLevel。)

对应的最小探测 Fragment Shader 不做任何光照计算,只根据传入的 u_target_lod 直接采样输出:

#version 460 core
precision highp float;

layout(binding = 0) uniform ProbeInfo {
  float target_lod;
} u_info;

uniform samplerCube u_env_probe; // 对照组切换为 sampler2D u_atlas_probe
in vec3 v_dir;
out vec4 frag_color;

void main() {
  frag_color = vec4(textureLod(u_env_probe, normalize(v_dir), u_info.target_lod).rgb, 1.0);
}

2. 控制路径与修复前后的输出对照

有了纯色探针之后,固定测试 target_lod = 0.0、2.0、3.0 三个输入,再增加一条“同一份像素数据改走二维纹理图集(2D Atlas)”的控制路径,对比结果如下:

实验路径                                      target_lod=0.0   target_lod=2.0   target_lod=3.0
----------------------------------------------------------------------------------------------
1. 官方基线 Engine + Cubemap 手工 mip 链       纯红 (#FF0000)   纯红 (#FF0000)   纯红 (#FF0000)
2. 官方基线 Engine + 2D Atlas 等价控制路径     纯红 (#FF0000)   纯蓝 (#0000FF)   纯黄 (#FFFF00)
3. 本地补丁 Engine + Cubemap 显式手工 mip 链   纯红 (#FF0000)   纯蓝 (#0000FF)   纯黄 (#FFFF00)

对比第 1 组和第 2 组可以看到:Uniform 字节布局、Dart 调用栈与 textureLod 参数完全一致,2D 控制路径能准确读出第 2 级的纯蓝和第 3 级的纯黄,仅受影响的纹理路径不管请求第几级都返回第 0 级的纯红。这排除了业务层参数传递和 Shader 语法问题,说明请求的 LOD 是在底层采样器描述符上被限制在了 0。

3. 默认采样器保护与两类纹理契约

定位到底层采样器的 LOD 限制后,需要注意不能直接移除默认采样器的最大 LOD 限制。

在移动图形后端上,这里实际存在两类完全不同的使用契约:

  • 仅上传第 0 级、或依赖引擎自动生成 mip 的普通纹理:在部分移动 GPU 与特定驱动版本上,自动生成 mip 或未完成布局同步的层级存在已知硬件兼容问题,引擎保留采样范围保护(workaround)是为了避免采样到未初始化或损坏的内存。
  • 由调用方逐级、逐面完整写入并完成同步转换的手工 mip chain:从 level 0 到最后一级 1×1 的全部数据都由调用方显式提供,并不经过自动 mip 生成链路。

如果为了恢复手工环境贴图的粗糙度模糊而直接移除普通采样器的保护逻辑,只上传了 mip 0 的常规纹理就可能在某些移动 GPU 上重新触发花屏或崩溃;反过来,如果业务侧并没有真正写满全部层级,也不应开启手工完整 mip 链的采样选项。稳妥的修复边界是:保留默认路径的驱动保护不动,仅在调用方显式声明已提供完整手工 mip chain 时,允许对应采样器读取 0..mipCount-1 的非零 LOD(对应上游跟进补丁 flutter/flutter#189966)。

4. 上层渲染库的过渡处理:启动期采样探测与图集回退

在引擎侧的采样器改动随稳定版 SDK 普及之前,上层三维渲染库仍需在已发布的引擎版本上正常渲染环境光反射。在 bdero/flutter_scene#242 中,针对 Android 原生端的环境贴图布局采用了“启动期实测探测 + 二维辐射度图集回退”的过渡方案:

  1. 避免按设备型号维护黑名单:在受影响的引擎版本上,doesSupportManuallyMippedTextures 等能力位依然会返回 true。如果靠拼接 GPU 名称猜测驱动行为,既容易漏掉同系列设备,后续引擎修复后也无法自动恢复。
  2. 启动期最小采样探测(probePlatformMipSampling):在 Scene.initializeStaticResources 加载完基础着色器后,仅在 Android 原生端构造一张 32×32、共 5 级的临时纹理,将第 0 级填为纯白(255)、第 1..4 级填为纯黑(0),再通过全屏矩形缩小绘制到 4×4 渲染目标(8 倍缩小,开启 MipFilter.linear)并回读中心像素。
  3. 按实测结果选择环境贴图布局:若回读亮度低于阈值(< 128),说明当前引擎与设备能够正常读取高层级黑色 mip,环境贴图继续使用 Cubemap mip 布局;若回读结果仍接近纯白,或在探测完成前就需要构建环境贴图,EnvironmentMap.shouldUseMipRadianceLayout 则保守回退到二维辐射度图集(2D Radiance Atlas)路径。

这样在受影响的 Android Vulkan 设备上可以先恢复随粗糙度变化的反射模糊效果,等后续包含引擎修复的版本到达客户端后,启动探测会自动放行 Cubemap 路径,无需再次修改上层库代码。同时也需要区分问题边界:切换辐射度图集只解决环境反射停留在第 0 级的问题,若移动端画面中还存在受阴影偏移或精度影响的条纹伪影,仍需在阴影通路上单独排查,不应混入环境光布局改动中。

5. 本地引擎验证与跨 GPU 对照边界

涉及本地编译 Flutter Engine(local-engine)验证图形补丁时,有四条边界需要在记录结果前逐项核对:

  1. 严格锁定同一套 Framework Revision:未修改的 Baseline Engine 与打上补丁的 Patched Local Engine 必须基于同一个 Flutter Framework 提交构建和运行。如果一边运行全局稳定版 SDK,另一边运行主干分支编译出的本地引擎,中间夹杂的其他提交会影响对照结论。
  2. 引擎描述符单测不能替代真机探针:Host 侧针对 SamplerDescriptor 参数的 C++ 单元测试只能证明分支逻辑按预期设置了 max_lod,真机 Vulkan 后端与驱动是否真正按该层级采样,仍以真机运行颜色化 LOD Probe 的屏幕输出为准。
  3. 保留跨 GPU 控制组:Android 生态的 SoC 与 GPU 驱动分歧很大。某一台设备上画面正常,只能说明该 GPU 未命中特定驱动分支,不能推导出所有 Android 设备都不受影响;排查记录中应写明具体的 Engine Revision、设备型号、SoC / GPU 名称以及启动时的图形后端日志。
  4. 区分探针验证与完整场景验证:把本地补丁引擎接入完整的三维场景并看到金属粗糙度过渡自然,说明补丁在上层渲染流程中工作正常;而“底层确实采样到了预期的第 N 级 mip”这一结论,始终由前面的纯色 LOD Probe 给出确定性证明。