4.2 分辨率、偏差与走样
实时 PBR 与阴影贴图
那篇在讲级联阴影时,会把 bias 当成一个「调一下就好」的经验参数;本节要回答的是它到底是多少、为什么是这个量级,以及把 bias 调大之后代价出现在哪里。这两个问题都能用解析式加实测曲线回答,不需要引用任何文献常数。
自遮挡从哪来
阴影贴图的判定是 ld - map[ty, tx] > bias。理想情况下,一个被光源照亮的点,它自己的深度 ld 与深度图里记的 map[ty, tx] 应该相等,差是 0。但实际有两个误差源:
- 深度量化:深度图把世界深度区间 $[near, far]$ 线性映到 Q16.16 的 $[0, \text{ONE}]$,1 个 LSB 就是一个量化步长。光照表面在深度图里被量化到一级级「台阶」上,主相机插值出来的
ld落在台阶之间,于是有一半左右的像素会得到ld > map。 - 纹素尺寸 vs 表面斜率:深度图的一个纹素覆盖世界空间里一段长度,这段长度投影到光线方向上就有一段深度跨度。斜面越陡(与光源方向夹角越大),一个纹素跨过的深度就越多,
ld与map的落差就越大。
两者叠加,就是经典的自遮挡(shadow acne)——亮面上出现一片一片的条纹。它和几何无关,纯粹是「拿有限分辨率的深度图去问连续表面的深度」造成的。
解析:偏差该多大
先把深度量化步长算出来。光源正交投影覆盖的世界深度区间是 far - near:
# snippet: lantern/sim/cg_shade_04_2_bias.py(深度量化步长)
span_world_q16 = far_q16 - near_q16 # 5242880
lsb_world_q16 = span_world_q16 // ONE # 80
far = 6553600、near = 1310720(世界单位 100 / 20),区间 5242880 个 Q16.16 单位;Q16.16 在 $[0, 1]$ 上有 65536 个刻度,所以一个 LSB 对应 80 个 Q16.16 单位的世界深度。
再看纹素。正交投影盒边长 $2 \times orthoHalf$ 铺满 256 个纹素:
# snippet: lantern/sim/cg_shade_04_2_bias.py(每纹素覆盖的世界尺寸)
texel_world_q16 = (2 * half_world_q16) // RES # 20480
half = 2621440(世界单位 40),边长 5242880,除以 256 得每纹素 20480 个 Q16.16 单位(世界单位 0.3125)。
平坦地形法线近似 $+Y$,与光照方向 $L$ 的夹角余弦就是 $|L_y|$;把 $L$ 归一化后:
# snippet: lantern/sim/cg_shade_04_2_bias.py(每纹素深度步长的解析上界)
ndl_flat = abs(light[1]) # 37837 (0.5774)
tan_theta = fsqrt(max(0, ONE - fmul(ndl_flat, ndl_flat))) # 92682 (1.4142)
tan_theta = fdiv(tan_theta, max(1, ndl_flat))
step_analytic = fmul(texel_world_q16, tan_theta) # 28963
即:一个纹素跨过的深度 ≈ 纹素世界尺寸 × tanθ ≈ 28963 个 Q16.16 单位。这就是偏差的解析上界——bias 只要不小于它,最陡的斜面也不会自遮挡。
实测:每个像素到底需要多少偏差
解析式给的是上界,真实场景里大部分像素的斜率远没有这么陡。脚本用无限分辨率的真值(ground_truth_mask:在光源屏幕空间做竖直射线求交,等价于分辨率趋于无穷的深度图)把「偏差误差」与「分辨率走样」两个误差源分开,然后对每个真值可见的像素,量出它需要的偏差 md - ld:
need_bias_p50 : 1
need_bias_p95 : 184
need_bias_p99 : 1012
need_bias_max : 5620
step_measured_median : 188
step_measured_p95 : 576
step_measured_max : 5845
读法:中位数只有 1——一半的可见像素几乎不需要偏差,因为它们的斜率接近 0;p95 是 184,p99 是 1012,最大值 5620。实测的相邻纹素深度差(step_measured_*)与这个分布同量级:中位数 188、p95 576、最大 5845,都比解析上界 28963 小得多。原因不神秘:解析式假设整个纹素都落在最陡的斜面上,而山谷里真正那么陡的三角形很少。所以实践中的 bias 取在 p95 到 p99 之间,而不是解析上界。
权衡曲线:自遮挡 ↔ 阴影脱离
偏差调大,自遮挡减少,但阴影会从遮挡物根部脱开(peter-panning)——因为 ld - map > bias 里的 bias 是单向的,它同时也在放过本该被遮挡的像素。脚本扫一遍偏差,逐档数两类错:
| bias (Q16.16) | 自遮挡 acne | 阴影脱离 peter-panning | 正确命中 hit |
|---|---|---|---|
| 0 | 22360 | 17 | 442 |
| 16 | 20178 | 17 | 442 |
| 32 | 17909 | 17 | 442 |
| 64 | 13565 | 20 | 439 |
| 128 | 5625 | 28 | 431 |
| 256 | 946 | 45 | 414 |
| 512 | 264 | 80 | 379 |
| 1024 | 112 | 172 | 287 |
| 2048 | 24 | 347 | 112 |
| 4096 | 0 | 459 | 0 |
曲线形状很清楚:bias = 0 时自遮挡 22360 像素、脱离只有 17;到 bias = 256 自遮挡降到 946,脱离升到 45;到 bias = 2048 自遮挡只剩 24,但脱离已经 347,正确命中从 442 掉到 112。没有免费的档位——4.1 选 bias = 256,是在「自遮挡 946 像素」与「脱离 45 像素」之间取的折中;如果场景换成更陡的几何,这个折中点会整体右移。这张曲线也解释了为什么工程上很少用「加大 bias」解决一切:脱离一旦发生,看起来是影子飘在半空,比自遮挡更难接受。
分辨率:走样与自遮挡的另一个源头
偏差能压住量化误差,压不住分辨率带来的走样:深度图的纹素越粗,一个纹素覆盖的表面越斜、跨度越大,自遮挡就越严重。固定 bias = 256 扫分辨率:
| 深度图 | 自遮挡 acne | 阴影脱离 | 深度图覆盖纹素 |
|---|---|---|---|
| 64² | 13009 | 75 | 710 |
| 128² | 5553 | 74 | 2846 |
| 256² | 946 | 45 | 11409 |
| 512² | 340 | 28 | 45646 |
从 64² 到 512²,自遮挡从 13009 掉到 340——降了 38 倍,而纹素总数只涨了 64 倍(64² → 512²)。这是「分辨率换质量」的直接证据,也是级联阴影存在的理由:与其把一张深度图开得很大,不如在靠近相机的地方用小投影盒、在远处用大投影盒。代价是深度图的显存与光源趟的填充率,512² 的覆盖纹素是 256² 的 4 倍。
S 层脚本
整段推导、实测与两条曲线都在一个脚本里,真跑得出:
#!/usr/bin/env python3
# file: lantern/sim/cg_shade_04_2_bias.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_04_2_bias.py
"""4.2《分辨率、偏差与走样》:偏差的解析推导 + 实测的「自遮挡 ↔ 阴影脱离」权衡曲线。
三个量都直接量出来,不引用任何文献常数:
1. **深度量化步长**:深度图把世界深度区间 `[near, far]` 线性映到 Q16.16 的 `[0, ONE]`,
故 1 个 LSB 对应多少世界单位是纯算术。
2. **每纹素深度步长**:在深度图上量相邻纹素的深度差,得到「表面斜率 × 纹素尺寸」的
实测分布;它的高分位数就是偏差必须盖住的自遮挡量。
3. **真值**:用 `shadowlib.ground_truth_mask` 在光源屏幕空间做竖直射线求交,等价于
**无限分辨率**的深度图,从而把「偏差误差」与「分辨率走样」两个误差源分开。
然后扫偏差,逐档数出 `acne`(真值可见却被判遮挡)与 `peter_panning`(真值被遮挡却判可见),
得到权衡曲线,并落一张 SVG。
"""
import hashlib
import json
import os
import sys
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import cglib # noqa: E402
import cgshade # noqa: E402
import cg_shade_04_shadowlib as lib # noqa: E402
OUT = os.path.join(ROOT, "out")
os.makedirs(OUT, exist_ok=True)
ONE = cglib.ONE
ORACLE_EPS = 8 # 真值判定的自遮挡护栏(Q16.16 的 8 个 LSB)
spec = cglib.load_spec(os.path.join(ROOT, "config", "scene_spec.json"))
sh = cglib.load_spec(os.path.join(ROOT, "config", "cg_shade_04_shadow.json"))
W, H = spec["width"], spec["height"]
RES = sh["lightRes"]
def run(res, bias):
"""一次两趟渲染,返回结果字典 + 真值掩码 + 每像素「所需偏差」分布。"""
r = lib.render_shadow_frame(spec, sh, res, bias, 1)
truth = lib.ground_truth_mask(r["shadow_tris"], r["light_uvd"], r["covered_mask"], ORACLE_EPS)
cov = r["covered_mask"]
return r, truth, cov
# ---------------------------------------------------------------- 解析量
near_q16 = sh["nearQ16"]
far_q16 = sh["farQ16"]
span_world_q16 = far_q16 - near_q16 # 光源正交投影覆盖的世界深度区间
lsb_world_q16 = span_world_q16 // ONE # 深度 1 LSB 对应的世界单位(Q16.16)
half_world_q16 = sh["orthoHalfQ16"]
texel_world_q16 = (2 * half_world_q16) // RES
light = cglib.v_norm(tuple(sh["lightDirQ16"]))
# 平坦地形的法线 ~ +Y,故 n·L 给出表面与光源方向的夹角余弦
ndl_flat = abs(light[1])
tan_theta = cglib.fsqrt(max(0, ONE - cglib.fmul(ndl_flat, ndl_flat)))
tan_theta = cglib.fdiv(tan_theta, max(1, ndl_flat))
# 每纹素深度步长(解析上界):纹素尺寸 × tan(theta)
step_analytic = cglib.fmul(texel_world_q16, tan_theta)
# ---------------------------------------------------------------- 实测:深度图相邻纹素差
r0, truth0, cov0 = run(RES, 0)
dmap = r0["shadow_depth"]
valid = dmap != lib.DEPTH_MAX
dx = np.zeros_like(dmap)
dx[:, :-1] = dmap[:, :-1]
dh = np.where(valid[:, :-1] & valid[:, 1:], np.abs(dmap[:, 1:] - dmap[:, :-1]), 0)
dv = np.where(valid[:-1, :] & valid[1:, :], np.abs(dmap[1:, :] - dmap[:-1, :]), 0)
steps = np.concatenate([dh[dh > 0], dv[dv > 0]])
step_median = int(np.median(steps))
step_p95 = int(np.percentile(steps, 95))
step_max = int(steps.max())
# ---------------------------------------------------------------- 实测:每像素「所需偏差」
shift = lib.res_shift(RES)
lu = r0["light_uvd"][:, :, 0]
lv = r0["light_uvd"][:, :, 1]
ld = r0["light_uvd"][:, :, 2]
tx = (lu >> shift) >> 6
ty = (lv >> shift) >> 6
inb = cov0 & (tx >= 0) & (tx < RES) & (ty >= 0) & (ty < RES)
md = np.where(inb, dmap[np.clip(ty, 0, RES - 1), np.clip(tx, 0, RES - 1)], lib.DEPTH_MAX)
delta = np.where(inb & (md != lib.DEPTH_MAX), md - ld, 0)
lit_by_truth = inb & (~truth0) & (md != lib.DEPTH_MAX)
need = delta[lit_by_truth]
need_p50 = int(np.percentile(need, 50))
need_p95 = int(np.percentile(need, 95))
need_p99 = int(np.percentile(need, 99))
need_max = int(need.max())
# ---------------------------------------------------------------- 扫偏差:权衡曲线
curve = []
for b in sh["biasSweep"]:
r, truth, cov = run(RES, b)
m = r["shadow_mask"] & cov
t = truth & cov
acne = int((m & ~t).sum())
pan = int((t & ~m).sum())
hit = int((m & t).sum())
curve.append({"bias": int(b), "acne": acne, "peter_panning": pan, "hit": hit})
# ---------------------------------------------------------------- 扫分辨率
res_curve = []
for res in sh["resolutionSweep"]:
r, truth, cov = run(res, sh["pcfBias"])
m = r["shadow_mask"] & cov
t = truth & cov
res_curve.append({
"res": int(res),
"acne": int((m & ~t).sum()),
"peter_panning": int((t & ~m).sum()),
"map_covered": int(r["map_covered"]),
})
truth_px = int((truth0 & cov0).sum())
report = {
"canvas": [W, H],
"lightRes": RES,
"oracle_eps": ORACLE_EPS,
"oracle_shadow_pixels": truth_px,
"oracle_shadow_permille": round(1000.0 * truth_px / max(1, int(cov0.sum())), 1),
"depth_span_world_q16": int(span_world_q16),
"depth_lsb_world_q16": int(lsb_world_q16),
"texel_world_q16": int(texel_world_q16),
"ndl_flat_q16": int(ndl_flat),
"tan_theta_q16": int(tan_theta),
"step_analytic_q16": int(step_analytic),
"step_measured_median": step_median,
"step_measured_p95": step_p95,
"step_measured_max": step_max,
"need_bias_p50": need_p50,
"need_bias_p95": need_p95,
"need_bias_p99": need_p99,
"need_bias_max": need_max,
"bias_curve": curve,
"resolution_curve": res_curve,
"per_pixel_sha256_bias0": hashlib.sha256(
np.clip(r0["color"], 0, 255).astype(np.uint8).tobytes()
).hexdigest(),
}
with open(os.path.join(OUT, "cg_shade_04_2_bias.json"), "w") as f:
json.dump(report, f, indent=2)
# ---------------------------------------------------------------- SVG 曲线
Wp, Hp = 640, 400
pad = 56
xs = [c["bias"] for c in curve]
acne = [c["acne"] for c in curve]
pan = [c["peter_panning"] for c in curve]
xmax = max(xs)
ymax = max(max(acne), max(pan)) or 1
def sx(v):
return pad + (Wp - 2 * pad) * (v / xmax)
def sy(v):
return Hp - pad - (Hp - 2 * pad) * (v / ymax)
def poly(vals):
return " ".join(f"{sx(x):.1f},{sy(y):.1f}" for x, y in zip(xs, vals))
svg = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{Wp}" height="{Hp}" viewBox="0 0 {Wp} {Hp}">',
f'<rect width="{Wp}" height="{Hp}" fill="#0f1116"/>',
f'<line x1="{pad}" y1="{Hp-pad}" x2="{Wp-pad}" y2="{Hp-pad}" stroke="#4a5568"/>',
f'<line x1="{pad}" y1="{pad}" x2="{pad}" y2="{Hp-pad}" stroke="#4a5568"/>',
f'<polyline points="{poly(acne)}" fill="none" stroke="#ff6b6b" stroke-width="2"/>',
f'<polyline points="{poly(pan)}" fill="none" stroke="#4dabf7" stroke-width="2"/>',
f'<text x="{Wp/2}" y="{Hp-16}" fill="#cbd5e0" font-size="13" text-anchor="middle">bias (Q16.16 LSB)</text>',
f'<text x="{pad-8}" y="{pad-16}" fill="#ff6b6b" font-size="12">acne</text>',
f'<text x="{pad+52}" y="{pad-16}" fill="#4dabf7" font-size="12">peter-panning</text>',
"</svg>",
]
with open(os.path.join(OUT, "cg_shade_04_2_bias.svg"), "w") as f:
f.write("\n".join(svg))
for k, v in report.items():
if k.endswith("curve"):
print(f"{k}:")
for row in v:
print(" ", row)
else:
print(f"{k:24s}: {v}")
最后那个 per_pixel_sha256_bias0 是 bias = 0 那一档的整帧缓冲摘要,用来钉住「偏差改变的是哪些像素」——把它和 4.1 在 bias = 256 下的摘要 33fba58a… 对比,就能看出偏差修正了多少像素。
偏差的解析式与权衡曲线到这里闭合:bias 的量级由「深度 LSB × 纹素斜率」决定,而它的取值由「自遮挡 ↔ 脱离」的折中决定。下一节换一个方向——不再只问「挡没挡住」,而是问「挡了多少」,把硬边界变成一条半影。
阅读导航:上一节:4.1 阴影贴图的原理 · 下一节:4.3 软阴影与 PCF 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。