GPU Gems 1 · Chapter 27. A Framework for Image Processing
用 filter graph、GPU 常驻 Image/Buffer 和 pull 更新模型,把 2D 图像滤镜从底层图形 API 中隔离出来。
学习目标
- 能把一个 2D 图像处理任务拆成 source、filter 和 sink,并画出可拉取的 filter graph
- 能解释 Image、Buffer、引用计数和 GPU texture 的边界,避免在每个算子间发生系统内存拷贝
- 能用 dirty 和 pull 更新模型决定何时重新执行 GPU pass,并识别缓存失效责任
- 能实现一个 screen-aligned quad 加 fragment shader 的滤镜,并用参数和回归数据验证图像管线
GPU 的 3D API 很底层:纹理、上下文、pbuffer、shader 绑定和矩形绘制都暴露给调用者。GPU Gems 1 第 27 章的回答不是再写一套更长的绘制脚本,而是定义一个面向图像处理的 C++ 隔离层,让应用用算子连接滤镜图,实际像素工作尽量留在显卡上。
↡由 Image 数据对象和 source、filter、sink 算子组成、用于连接图像处理步骤的有向网络 把“图像是什么”和“如何变换图像”分开。这个分离使视频流、批量合成和交互预览可以复用同一套节点组合,而应用只需更换图或参数。
1. 先建立三类对象:数据、操作和边界
框架里的数据对象是 Image;操作对象则按职责分为三类。产生图像但不消耗输入的加载器是 source,消费输入并产生输出的滤镜是中间节点,只消费最终结果而不产生输出的视图是 sink。ImageFilter 同时继承 source 和 sink,因此能把一个滤镜的输出继续接给下一个滤镜。
↡产生图像输出、通常从磁盘或采集设备得到 Image 而不消费上游图像的算子 的 image() 可以按需加载或返回缓存图像;dirty() 说明最近一次结果是否仍然有效。↡消费图像输入但不产生新的管线输出、典型职责是显示结果的算子 则持有一个 source 引用,通过 setSourceOperator() 连接上游。视图的 zoom、pan、center 和 reshape 属于显示边界,不应混入滤镜的核心算法。
class SourceOperator {
public:
virtual bool dirty() = 0;
virtual Image image() = 0;
};
class SinkOperator {
protected:
SourceOperator* source_ = nullptr;
public:
void setSourceOperator(SourceOperator* source) { source_ = source; }
};2. 用 pull 模型让结果请求驱动计算
框架选择从结果节点向上游 pull,而不是让每个 source 主动把新数据 push 到所有下游。原因是应用经常只需要当前 view 的结果;如果一个参数没有改变,就没有理由让整条图重新执行。
↡表示某个算子或其依赖输出已变化、因此缓存结果不能直接复用的状态标记 的传播责任必须由每个算子实现者正确维护。ImageFilter 的默认 dirty() 可以递归询问上游;如果上游没有变化,结果节点直接复用缓存。如果 sigma、颜色转换或输入文件名改变,相关节点要标记 dirty,让下一次 image() 重新生成结果。
Image ImageFilter::image() {
Image input = source_->image();
if (!output_.valid() || dirty()) {
output_.setSize(input.width(), input.height());
renderFilter(input, output_);
clearDirtyFlag();
}
return output_;
}这里的缓存边界要通过测试确认:改变参数后第一次 pull 应产生新结果;连续两次 pull 且无参数变化,不应新增 GPU pass;改变上游输入时,下游要能观察到依赖链上的 dirty 状态。
3. Image 是 GPU 资源的高层 handle
原章把 Image 设计成实际 GPU Buffer 的 proxy。调用者可以取得 texture ID、开始和结束向当前图像绘制,也可以查询和调整宽高;但不需要知道纹理对象、pbuffer、设备上下文和引用计数的全部细节。
↡用于共享 GPU 图像资源的计数式所有权机制,最后一个 Image handle 释放后才销毁底层 Buffer 让多个 operator 安全地持有同一张图。复制 Image 只增加 Buffer 的引用数,最后一个 handle 消失时才释放 Buffer;这比在每个滤镜里手写创建和删除职责更不容易产生悬挂资源。
Image input = source_->image();
input.renderBegin();
drawFilterQuad(input.textureID(), kernelHandle);
input.renderEnd();实际实现应让重复使用的 Image 尽量留在 GPU 内存中。renderBegin() 与 renderEnd() 包住对当前图像的绘制,下一阶段可以直接把结果当作纹理读取;只有明确需要导出、编码或 CPU 分析时,才设计受控的 readback 边界。
4. render-to-texture 隐藏滤镜的共同机械动作
处理一张图时,滤镜把输入 Image 绑定为纹理,把屏幕对齐的 quad 渲染到不可见的 pixel buffer;fragment program 对每个输出像素执行采样、卷积或颜色变换,输出 buffer 完成后再绑定成下一节点的纹理。
↡将渲染结果写入不可见 GPU buffer、随后把该 buffer 绑定为纹理供下一个阶段读取的处理路径 是这个隔离层的关键。原章使用 OpenGL 的 pbuffer、render-to-texture 扩展和 Cg;现代 API 可以换成 framebuffer、render pass 或 compute dispatch,但“输入资源、处理阶段、输出资源”的边界仍然相同。
Image ImageFilter::image() {
Image input = source_->image();
Image output;
output.setSize(input.width(), input.height());
output.renderBegin();
bindVertexProgram(vertexIdentity_);
bindFragmentProgram(cgFragmentProgram());
setCgParameters();
drawScreenAlignedQuad(input.textureID());
output.renderEnd();
return output;
}新滤镜因此只需要两块代码:一个 fragment shader,以及把 sigma、kernel 或颜色参数写入 shader 的 C++ 方法。框架集中处理资源绑定和上下文切换,滤镜实现不必重复这些容易出错的步骤。
5. half、NPOT 与卷积参数决定实际可用性
原章选择 half 作为颜色表示,是在速度、精度和显存占用之间取折中;OpenEXR 也使用相同的 16 位浮点结构,因此 HDR 图像可以在标准文件和 GPU buffer 之间往返。浮点中间结果还能避免在每个步骤都把值夹到零到一,只有最后显示阶段才需要归一化或 tone mapping。
图像和视频分辨率通常不是二的幂,因此框架还考虑 NPOT rectangle texture。输入尺寸、half/float 格式、纹理过滤限制和显存预算必须在创建 Buffer 时显式验证;不能只在一张二的幂测试图上证明管线可用。
高斯滤镜的 fragment program 对 kernel 覆盖的像素逐项相乘、求和,C++ 侧设置 sigma 产生的 kernel 数组。原章示例中半径为 3 时核元素数量为 49;实现可以调整半径,但要同时更新采样坐标、参数长度和性能预算。
float4 gauss(float2 center : TEXCOORD0) : COLOR {
float4 result = 0;
for (int i = 0; i < KERNEL_COUNT; ++i) {
result += texRECT(inputImage, center + offsets[i]) * kernel[i];
}
return result;
}6. 复合滤镜:把算法表达成可观察的子图
原章的夜视示例先把彩色图像转换成蓝色调,再做模糊,最后执行边缘锐化;ScotopicFilter 把这条小管线封装成复合滤镜。这个例子说明复合效果不必成为一个无法调试的超级 shader:只要节点边界清楚,就可以分别检查输入、参数、dirty 状态和每个中间结果。
Image Processing Framework Lab
先预测:参数不变时,pull 更新还需要几次 GPU pass?
切换 pipeline 或调参数会重新计算;取消 dirty 后再次 pull,结果应留在缓存路径。
交互实验把 pipeline、分辨率和 dirty 状态显式化。选择更长的 scotopic 图会增加中间阶段和 GPU pass;取消 dirty 后再次 pull,pass 应回到零并复用缓存。实验数据是确定性的因果示意,不替代真实 GPU profiler;发布实现仍要测量显存、纹理带宽、shader 时间、目标分辨率和 CPU/GPU 并行度。
7. 性能边界:隔离层不是免费抽象
GPU 是一个专用的第二处理器,多处理器程序天然比单处理器程序更复杂。适合 GPU 的图像算法应尽量让 CPU 和 GPU 都有工作,同时避免频繁同步和读回;最大纹理尺寸、显存、缓存和目标硬件差异也必须纳入设计。
框架主要展示了可组合的基础,而不是工业级任意大图解决方案。原章明确没有覆盖把大图分 tile、跨设备调度等更复杂方案。实际发布时应给出尺寸上限、格式 fallback、失败时释放 Buffer 的路径,以及对不适合 GPU 的小图或低延迟操作的 CPU fallback。
小结:用图、缓存和资源边界驯服底层 API
- filter graph 把 Image 数据和 source、filter、sink 算子连接起来,应用可以替换节点而不重复底层图形 API 胶水。
- pull 更新从结果节点向上游索取数据,dirty 传播决定缓存是否失效;参数不变时应复用已有 Image。
- Image 是 GPU Buffer 的高层 handle,引用计数负责资源生命周期,render-to-texture 让中间图像保持在显卡上。
- screen-aligned quad、fragment shader 和 uniform 参数构成可复用的滤镜实现;half、NPOT、卷积半径和显存预算决定实际边界。
- 复合夜视滤镜、性能测量、尺寸限制和 CPU fallback 是从教学框架走向发布管线时必须补齐的验证项。
练习
问题 1|画图与职责 一个应用要加载图片、执行高斯模糊、再显示结果。请标出 source operator、ImageFilter 和 sink operator,并说明为什么 ImageView 不应该自己实现模糊。
问题 2|推理题 连续两次调用结果节点的 image(),两次调用之间没有参数或输入变化。基于 pull 和 dirty 设计,预期发生几次新的 GPU pass?如果 sigma 改变一次呢?
问题 3|实现题 设计一个发布前回归矩阵,证明一个新的 GPU 滤镜没有不必要的 CPU 往返,并且在 NPOT 图像上正确工作。至少写出五个测试观察量。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- filter graph
- 由 Image 数据和 source、filter、sink 算子组成的图像处理有向网络。
- source operator
- 产生 Image 输出而不消费上游图像的算子。
- sink operator
- 消费图像输入但不产生管线输出、通常负责显示的算子。
- dirty
- 表示算子或依赖输出变化、缓存不能直接复用的状态。
- reference counting
- 以引用数管理 GPU Buffer 生命周期的共享所有权机制。
- render-to-texture
- 把渲染结果写入 GPU buffer 并绑定为下一阶段纹理的路径。