10.1 卷积与模糊
站内 /graphics-post-processing/ 那篇讲的是后处理链怎么编排、渲染目标怎么切换;本节往下钻一层,问链上第一环——卷积——在整数下到底该怎么写。模糊、锐化、边缘检测、Bloom 的第一步,全都是同一个动作:把每个像素换成它邻域按核权重的加权和。
这一节要钉死三件事。第一,核权重之和必须恰好等于 ONE(65536);差一点,整帧就会整体变亮或变暗,而且这个偏差会随每一级后处理累积。第二,边界外的像素怎么取,clamp / wrap / mirror 三种约定在距边界 r 像素以内给出不同的结果,但内部必须完全一致。第三,半径 r 的二维核有 (2r+1)^2 个抽头,拆成两次一维卷积只剩 2(2r+1) 个——这是后处理里最划算的一次化简。
判据照旧是像素缓冲的 sha256:同一份整数逻辑,Python 与 TypeScript 在四个半径下的模糊结果必须逐字节相同。
核:权重和必须恰好等于 ONE
二项式核(帕斯卡三角的一行)是高斯核的整数形态。半径 r 取第 2r 行系数 C(2r,k),整体左移 16-2r 位得到 Q16.16 权重;因为 Σ C(2r,k) = 2^(2r),移位后和恰好是 2^16 = ONE,残差恒为 0,不需要任何补偿。
朴素均值核就没这么幸运:ONE // (2r+1) 除不尽,r=4 时 9 个抽头各丢 7 个最低位,总和比 ONE 少 7。修法是把残差一次性补到中心抽头上,mean_q16(r, True) 的残差随即归零。
# snippet: 节选自 lantern/sim/cg_shade_10_1_convolution.py 的核构造与可分离卷积
def binom_row(n):
"""帕斯卡三角第 n 行(n+1 个系数)——半径 r 的高斯核取 n = 2r。"""
row = [1]
for _ in range(n):
row = [1] + [row[i] + row[i + 1] for i in range(len(row) - 1)] + [1]
return row
def gauss_q16(r):
"""半径 r 的二项式高斯核(Q16.16)。系数 C(2r,k) 乘 2^(16-2r),和恰为 ONE。"""
shift = 16 - 2 * r
assert shift >= 0, "半径上限 8(2r <= 16)"
return [c << shift for c in binom_row(2 * r)]
def mean_q16(r, compensate=False):
"""半径 r 的均值核(Q16.16)。返回 (weights, residual)。"""
taps = 2 * r + 1
base = ONE // taps
w = [base] * taps
if compensate:
w[r] += ONE - base * taps # 把残差补到中心抽头
return w, ONE - sum(w)
def convolve_separable(img, wq16, mode, normalize_each_axis=True):
"""两次 1D 卷积。normalize_each_axis=True 时每轴 >>16(标准做法);
False 时全精度累加、最后一次 >>32(与 2D 直接卷积逐位等价)。"""
r = len(wq16) // 2
def axis_pass(a, axis):
pad = [(0, 0), (0, 0)]
pad[axis] = (r, r)
p = np.pad(a, pad, mode=mode)
sw = np.lib.stride_tricks.sliding_window_view(p, len(wq16), axis=axis)
acc = np.zeros(a.shape, dtype=np.int64)
for k, wk in enumerate(wq16):
acc += wk * sw[..., k]
return (acc >> 16) if normalize_each_axis else acc
out = axis_pass(img.astype(np.int64), 1)
out = axis_pass(out, 0)
return (out >> 32) if not normalize_each_axis else out
实测四个半径的权重和与残差:
核权重(Q16.16,和应 == ONE = 65536):
r=1 gauss 和 = 65536 残差 = 0 均值核残差 = +1 补中心后 = +0
r=2 gauss 和 = 65536 残差 = 0 均值核残差 = +1 补中心后 = +0
r=4 gauss 和 = 65536 残差 = 0 均值核残差 = +7 补中心后 = +0
r=8 gauss 和 = 65536 残差 = 0 均值核残差 = +1 补中心后 = +0
可分离 vs 直接(全精度): 最大差 = 0
边界:clamp / wrap / mirror
卷积在图像边缘会「踩空」——(2r+1) 个抽头里有 r 个落在图外。三种约定分别是:clamp 复制边缘像素(等价于 np.pad(mode="edge"))、wrap 环绕到另一侧、mirror 镜像反射。
真实帧的顶部是一整行均匀背景,三种模式在那里给出完全一样的值(clamp-vs-mirror = 0),根本看不出差别。所以我另造了一张合成斜坡——行不变、列从 0 递增到 220——半径 2 的核在左边界立刻分出三种结果:
边界模式(真实帧前 8 行): clamp-vs-wrap 最大差 = 24 clamp-vs-mirror = 0 内部 = 0
边界模式(合成斜坡,r=2):
输入行 : [0, 20, 40, 60, 80, 100]
clamp : [7, 21, 40, 60, 80, 100]
wrap : [75, 35, 40, 60, 80, 100]
mirror : [15, 22, 40, 60, 80, 100]
clamp-vs-mirror 最大差 = 8 clamp-vs-wrap = 68 内部(>2 像素)= 0
clamp 在左端把第一个像素 0 复制了两次,结果被拉低到 7;wrap 从右端绕回来接上 220 附近的高值,左端反而冲到 75;mirror 反射出 20、40,得到居中的 15。三者差异只存在于距边界 r 像素以内,内部(>2 像素)= 0 说明内部完全一致——这条是回归测试的硬断言。
选哪一种不是审美问题。后处理链里对屏幕帧做模糊时,clamp 是默认答案:屏幕外的像素本来就不存在,把它当成「边缘像素的复制」在视觉上最不容易露馅,而且它天然对应 GPU 纹理的 CLAMP_TO_EDGE。wrap 只适合本身可平铺的贴图(比如 9.x 那几张贴图在 UV 上循环取样时);把 wrap 用在屏幕帧上,画面左边缘会渗进右边缘的内容,肉眼立刻能看出那条竖缝。mirror 居中、无接缝,代价是每个轴要多算一次坐标反射,在固定管线里没有硬件支持,一般只在离线渲染里用。
可分离:二维核拆成两次一维
一个 (2r+1)×(2r+1) 的核,如果它是可分离的(K = u ⊗ v,高斯核天然满足),就能先沿 x 卷一遍、再沿 y 卷一遍。抽头数从 (2r+1)^2 降到 2(2r+1),比值 (2r+1)/2 随半径线性增长。
实现上有两种归一化口径,两者必须分清:逐轴归一化(每轴累加后 >> 16)是标准做法,中间结果立刻回到 8 位量级;全精度(两轴都累加到 int64,最后一次 >> 32)与二维直接卷积逐位等价,可分离性只有在这个口径下才严格成立。实测 separable_vs_direct_max_diff = 0,也正说明两种口径的差别纯粹来自中间舍入,不是算法不同。
半径扫描(clamp,可分离逐轴归一化):
r 抽头1D 抽头2D 残差 ms可分离 ms直接 加速比
1 3 9 0 0.605 0.818 1.35x
2 5 25 0 0.894 2.152 2.41x
4 9 81 0 1.496 6.675 4.46x
8 17 289 0 2.721 24.117 8.86x
金标准 blur : out/cg_shade_10_1_blur.rgb 均值 = 49.2898 sha256 ba37d5a4cd92aa2c…
抽头比 81/9 = 9,实测加速比 4.46x;半径 8 时抽头比 289/17 = 17,加速比 8.86x。加速比约为抽头比的一半——因为可分离要做两趟,每趟都有各自的边界处理与内存往返。这正是后处理里「先可分离、再谈优化」的原因:它不改结果,只改常数。
在 GPU 上这个结论还会更强:两趟一维卷积可以各写成一个片元着色器,中间结果落在离屏纹理里,抽头数从 81 降到 9 意味着每像素的纹理采样次数少了 9 倍——而后处理的瓶颈通常正是纹理带宽,不是算力。代价是多一张中间纹理(320×240 的 RGBA8 只有 300 KB),以及一次额外的 FBO 往返。下一节的泛光链会反复用到这条:任何能在低分辨率上做的卷积,都不要在全分辨率上做。
跨语言核对:四个半径的缓冲逐字节相同
整数逻辑的验收标准不是「看起来一样」,而是缓冲的 sha256 相同。TypeScript 侧复刻同一套核构造与两种卷积口径,逐位断言:
// file: lantern/core/test/cg_shade_10_1_convolution.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_10_1_convolution.test.ts'
// 10.1 的跨语言断言:TypeScript 必须复现 Python 侧 sim/cg_shade_10_1_convolution.py 的
// 核权重、归一化残差、边界模式样例、可分离/直接等价,以及四个半径下模糊后的
// 像素缓冲 sha256(逐字节相同)。
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import {
type Mode,
blurGray,
blurGrayFull,
convolveDirectFull,
gaussQ16,
meanQ16,
} from "../src/lantern/cg_shade_10_conv.ts";
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const py = JSON.parse(readFileSync(join(root, "out/cg_shade_10_1_convolution.json"), "utf8")) as {
kernels: Record<string, { gauss_sum: number; gauss_residual: number; mean_residual: number;
mean_fixed_residual: number }>;
separable_vs_direct_max_diff: number;
boundary: { demo: { clamp_row0: number[]; wrap_row0: number[]; mirror_row0: number[];
clamp_vs_mirror: number; clamp_vs_wrap: number; interior_max_diff: number } };
sweep: { radius: number; residual: number; sha256: string }[];
};
const sha = (a: Uint8Array) => createHash("sha256").update(a).digest("hex");
// 与 Python 侧逐字一致的灰度化:trunc((R+G+B)/3)(像素非负,故 == floor)
const base = new Uint8Array(readFileSync(join(root, "out/shade_hollow.rgb")));
const W = 320;
const H = 240;
const gray = new Int32Array(W * H);
for (let i = 0; i < W * H; i += 1) {
gray[i] = Math.floor((base[i * 3] + base[i * 3 + 1] + base[i * 3 + 2]) / 3);
}
function toU8(a: Int32Array): Uint8Array {
const o = new Uint8Array(a.length);
for (let i = 0; i < a.length; i += 1) o[i] = a[i] < 0 ? 0 : a[i] > 255 ? 255 : a[i];
return o;
}
test("核权重和恰为 ONE,二项式高斯残差为 0,均值核残差可被中心补偿", () => {
for (const r of [1, 2, 4, 8]) {
const g = gaussQ16(r);
assert.equal(g.reduce((a, b) => a + b, 0), 65536, `r=${r} 高斯核和`);
assert.equal(py.kernels[r].gauss_residual, 0);
const { residual } = meanQ16(r, false);
assert.equal(residual, py.kernels[r].mean_residual);
assert.equal(meanQ16(r, true).residual, py.kernels[r].mean_fixed_residual);
assert.equal(meanQ16(r, true).residual, 0);
}
});
test("边界三模式在合成斜坡上的结果与 Python 侧逐值一致", () => {
const ramp = new Int32Array(12 * 12);
for (let y = 0; y < 12; y += 1) for (let x = 0; x < 12; x += 1) ramp[y * 12 + x] = x * 20;
const w2 = gaussQ16(2);
const d = py.boundary.demo;
const got = (m: Mode) => Array.from(blurGray(ramp, 12, 12, w2, m).slice(0, 12));
assert.deepEqual(got("edge"), d.clamp_row0);
assert.deepEqual(got("wrap"), d.wrap_row0);
assert.deepEqual(got("reflect"), d.mirror_row0);
// 边界模式只影响距边界 r 像素以内;内部必须完全相同
assert.equal(d.interior_max_diff, 0);
});
test("可分离(全精度)与 2D 直接卷积逐位等价", () => {
const w4 = gaussQ16(4);
const a = blurGrayFull(gray, W, H, w4, "reflect");
const b = convolveDirectFull(gray, W, H, w4, "reflect");
let maxd = 0;
for (let i = 0; i < a.length; i += 1) maxd = Math.max(maxd, Math.abs(a[i] - b[i]));
assert.equal(maxd, py.separable_vs_direct_max_diff);
assert.equal(maxd, 0);
});
test("【核心】四个半径模糊后的像素缓冲 sha256 与 Python 侧逐字节相同", () => {
for (const s of py.sweep) {
const img = toU8(blurGray(gray, W, H, gaussQ16(s.radius), "edge"));
assert.equal(sha(img), s.sha256, `r=${s.radius} 的模糊缓冲`);
}
});
✔ 核权重和恰为 ONE,二项式高斯残差为 0,均值核残差可被中心补偿 (0.643292ms)
✔ 边界三模式在合成斜坡上的结果与 Python 侧逐值一致 (0.94875ms)
✔ 可分离(全精度)与 2D 直接卷积逐位等价 (52.195167ms)
✔ 【核心】四个半径模糊后的像素缓冲 sha256 与 Python 侧逐字节相同 (38.312334ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 264.797375
四条断言全绿。第三条把「可分离 == 直接」钉在最大差 0 上;第四条是本节的核心判据——四个半径的模糊缓冲各 230400 字节,Python 与 TypeScript 的 sha256 完全相同。
需要说明的是,这里的「逐字节相同」成立有两个前提:灰度化用同一个 floor((R+G+B)/3)、归一化用同一个右移口径。少了任何一条,差的就是最低位,而最低位在连续多级后处理里会被放大。下一节 10.2 Bloom 与泛光
会把这个教训用在实际的泛光链上:那里每一步都要重新确认权重和。
阅读导航:上一节:9.3 程序化纹理的整数化 · 下一节:10.2 Bloom 与泛光 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。