9.3 双面材质
前两节都在做减法:先判朝向,再把背面丢掉。可「丢掉背面」这条规矩有一个前提——网格是封闭的。一旦遇到没有厚度的开曲面,减法就变成了事故。站内 /graphics-gpu-driven-rendering-lod/ 那篇处理的是 GPU 侧按 LOD 分块的批量剔除,同样默认几何是封闭的;本节反过来问:当一块面片从背面看时,朝向符号告诉我们它是背面,但我们并不想让它消失,该怎么办?
答案不是「关掉剔除」,而是把背面三角形的几何法线取反(N -> -N)再着色。这样它按「朝向观察者这一面」受光,既留在了画面里,又没有被错误地当成正面照亮。这就是双面材质(two-sided material)。
一个会露出背面的最小场景
要看清这件事,需要一个能同时从两面看的面片。本节用一个 4x4 的方格布帘:它躺在 z = 0 平面上,环绕顺序让几何法线恒为 +z。配置是一份全整数 JSON:
{
"name": "lantern-curtain",
"width": 128,
"height": 96,
"backgroundQ8": [12, 14, 20],
"ambientQ16": 13107,
"gridN": 4,
"extentQ16": 131072,
"albedoQ8": [90, 180, 220],
"frontCamera": {
"eyeQ16": [0, 0, -262144],
"targetQ16": [0, 0, 0],
"upQ16": [0, 65536, 0],
"tanHalfFovYQ16": 37837,
"nearQ16": 32768,
"farQ16": 4194304
},
"backCamera": {
"eyeQ16": [0, 0, 262144],
"targetQ16": [0, 0, 0],
"upQ16": [0, 65536, 0],
"tanHalfFovYQ16": 37837,
"nearQ16": 32768,
"farQ16": 4194304
},
"directionalLight": {
"dirQ16": [-37837, 37837, 37837],
"colorQ8": [255, 236, 200],
"intensityQ16": 58982
}
}
它有两台相机,位置只差一个符号:frontCamera 在 z = -4 处看向原点,backCamera 在 z = +4 处看向原点。布帘的法线恒为 +z,于是:
- 正面相机:法线背对相机,
area > 0,32 个三角形全是正面,单面渲染就够。 - 背面相机:法线朝向相机,
area <= 0,32 个三角形全是背面——单面渲染会得到一张空图。
双面材质:不剔除,而是把法线取反
实现很短:遍历三角形时先算屏幕空间面积符号;若为背面,就把几何法线取反,再交给同一个光照函数。注意环绕也要一起反绕,否则它会先被光栅化器的剔除挡在门外:
# snippet: 双面材质:背面法线取反并反绕(节选自 lantern/sim/cg_raster_09_3_two_sided.py)
is_front = area2x(...) > 0
if not is_front:
if mode == "flip":
n = (-n[0], -n[1], -n[2]) # 双面材质:按朝向观察者的一面受光
u = rev(t) # 反绕,让光栅化器收下它
下面这个脚本把四种渲染都跑一遍:正面相机、背面相机单面、背面相机双面(取反)、背面相机双面但不取反(负例),外加一个两遍渲染的等价实现。
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_09_3_two_sided.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_09_3_two_sided.py
"""9.3 双面材质:一块没有厚度的布帘,从正面与背面各看一次。
config/cg_raster_09_3_curtain.json 是一块 4x4 的开曲面(z = 0 平面上的方格布帘),
它的几何法线由环绕顺序决定,恒为 +z。于是:
正面相机(eye = -4z)看到的是背对相机的一侧 -> area > 0 -> 单面渲染就够
背面相机(eye = +4z)看到的是朝向相机的一侧 -> area <= 0 -> 单面渲染得到一张空图
双面材质的做法不是「不剔除」,而是**把背面三角形的几何法线取反**(N -> -N)再着色,
让它按「朝向观察者这一面」受光。负例是忘了取反:背面沿用朝外的 N,
于是背面被当成正面照亮,像素缓冲的 sha256 与正确实现不同。
产物:out/cg_raster_09_3_curtain_{front,back_single,back_two_sided,back_no_flip}.png、
out/cg_raster_09_3_curtain_back.rgb、out/cg_raster_09_3_two_sided.json
"""
import hashlib
import json
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
import render_raster as rr # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SPEC = os.path.join(ROOT, "config", "cg_raster_09_3_curtain.json")
def area2x(xy):
(ax, ay), (bx, by), (cx, cy) = xy
return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
def rev(t):
"""交换第二、三顶点:环绕符号取反,背面因此不再被剔除。"""
return (t[0], t[1], t[2], t[3], t[4], t[5], t[12], t[13], t[14], t[15], t[16], t[17],
t[6], t[7], t[8], t[9], t[10], t[11])
def build_curtain(grid_n, extent):
"""z = 0 平面上的 (grid_n+1)^2 顶点方格,环绕顺序使几何法线恒为 +z。"""
step = cglib.fdiv(2 * extent, grid_n << 16)
verts = []
for iy in range(grid_n + 1):
for ix in range(grid_n + 1):
verts.append((-extent + cglib.fmul(step, ix << 16), -extent + cglib.fmul(step, iy << 16), 0))
tris, row = [], grid_n + 1
for iy in range(grid_n):
for ix in range(grid_n):
a = iy * row + ix
tris.append((a, a + 1, a + row))
tris.append((a + 1, a + row + 1, a + row))
return verts, tris
def render(spec, verts, tris, cam, mode):
"""平顶着色 + 光栅化。mode: single 只画正面 / flip 双面且取反 / noflip 双面但不取反。"""
w, h = spec["width"], spec["height"]
mvp = cglib.mat_mul(
cglib.mat_perspective(cam["tanHalfFovYQ16"],
cglib.fdiv(cglib.f_from_int(w), cglib.f_from_int(h)),
cam["nearQ16"], cam["farQ16"]),
cglib.mat_look_at(tuple(cam["eyeQ16"]), tuple(cam["targetQ16"]), tuple(cam["upQ16"])))
scr = []
for v in verts:
p = cglib.project(mvp, v)
scr.append(None if p is None else (*cglib.to_screen(p[0], p[1], w, h), p[2]))
rtris, faces, front, back = [], [], 0, 0
for a, b, c in tris:
sa, sb, sc = scr[a], scr[b], scr[c]
if sa is None or sb is None or sc is None:
continue
A, B, C = verts[a], verts[b], verts[c]
n = cglib.v_norm(cglib.v_cross(cglib.v_sub(B, A), cglib.v_sub(C, A)))
t = (*sa, 0, 0, 0, *sb, 0, 0, 0, *sc, 0, 0, 0)
is_front = area2x(((t[0], t[1]), (t[6], t[7]), (t[12], t[13]))) > 0
if is_front:
front += 1
else:
back += 1
if mode == "flip":
n = (-n[0], -n[1], -n[2])
col = rr.shade_vertex(n, spec["albedoQ8"], spec["directionalLight"], spec["ambientQ16"])
u = t if (is_front or mode == "single") else rev(t) # single 模式保留原绕向,交给剔除
rtris.append((u[0], u[1], u[2], *col, u[6], u[7], u[8], *col, u[12], u[13], u[14], *col))
faces.append(is_front)
res = cglib.rasterize(rtris, w, h, spec["backgroundQ8"])
return res, front, back, rtris, faces
def main():
spec = json.load(open(SPEC, encoding="utf-8"))
verts, tris = build_curtain(spec["gridN"], spec["extentQ16"])
out = os.path.join(ROOT, "out")
front_res, ff, fb, _r, _f = render(spec, verts, tris, spec["frontCamera"], "single")
back_single, _sf, _sb, _r2, _f2 = render(spec, verts, tris, spec["backCamera"], "single")
back_two, bf, bb, back_rtris, faces = render(spec, verts, tris, spec["backCamera"], "flip")
back_bad, _nf, _nb, _r3, _f3 = render(spec, verts, tris, spec["backCamera"], "noflip")
# 两遍渲染:第一遍只画背面(反绕 + 法线取反),第二遍只画正面,深度缓冲决定谁在前
w, h, bg = spec["width"], spec["height"], spec["backgroundQ8"]
p_back = cglib.rasterize([t for t, f in zip(back_rtris, faces) if not f], w, h, bg)
p_front = cglib.rasterize([t for t, f in zip(back_rtris, faces) if f], w, h, bg)
two_pass = np.where((p_front[1] < p_back[1])[:, :, None], p_front[0], p_back[0])
two_pass_px = np.clip(two_pass, 0, 255).astype(np.uint8)
cglib.save_png(os.path.join(out, "cg_raster_09_3_curtain_back_two_pass.png"), two_pass)
px = {}
for name, res in (("front", front_res), ("back_single", back_single),
("back_two_sided", back_two), ("back_no_flip", back_bad)):
px[name] = np.clip(res[0], 0, 255).astype(np.uint8)
cglib.save_png(os.path.join(out, f"cg_raster_09_3_curtain_{name}.png"), res[0])
with open(os.path.join(out, "cg_raster_09_3_curtain_back.rgb"), "wb") as fh:
fh.write(px["back_two_sided"].tobytes())
sha = lambda a: hashlib.sha256(a.tobytes()).hexdigest()
diff = lambda a, b: int((a != b).any(axis=2).sum())
mean = lambda a: round(float(a.astype(np.float64).mean()), 4)
stats = {
"canvas": [spec["width"], spec["height"]], "triangles": len(tris),
"front_camera": {"front": ff, "back": fb, "covered": int(front_res[2].sum())},
"back_camera": {"front": bf, "back": bb, "culled_single": back_single[3],
"covered_single": int(back_single[2].sum()),
"covered_two_sided": int(back_two[2].sum()),
"covered_no_flip": int(back_bad[2].sum())},
"sha256_back_two_sided": sha(px["back_two_sided"]),
"sha256_back_no_flip": sha(px["back_no_flip"]),
"sha256_back_single": sha(px["back_single"]),
"sha256_front": sha(px["front"]),
"two_sided_vs_no_flip_diff": diff(px["back_two_sided"], px["back_no_flip"]),
"two_sided_vs_single_diff": diff(px["back_two_sided"], px["back_single"]),
"front_vs_back_two_sided_diff": diff(px["front"], px["back_two_sided"]),
"mean_front": mean(px["front"]),
"mean_back_two_sided": mean(px["back_two_sided"]),
"mean_back_no_flip": mean(px["back_no_flip"]),
"no_flip_is_front_mirrored": bool(np.array_equal(px["back_no_flip"], px["front"][:, ::-1, :])),
"sha256_back_two_pass": sha(two_pass_px),
"two_pass_equals_single_pass": bool(np.array_equal(two_pass_px, px["back_two_sided"])),
}
with open(os.path.join(out, "cg_raster_09_3_two_sided.json"), "w", encoding="utf-8") as fh:
json.dump(stats, fh, ensure_ascii=False, indent=2)
print(f"布帘 : {spec['gridN']}x{spec['gridN']} 格 -> {len(tris)} 个三角形(开曲面,无法线属性)")
print(f"正面相机 : front={ff} back={fb} covered={stats['front_camera']['covered']}")
print(f"背面相机 : front={bf} back={bb}")
print(f" 只画正面 : covered={stats['back_camera']['covered_single']}"
f" culled={stats['back_camera']['culled_single']}")
print(f" 双面(取反) : covered={stats['back_camera']['covered_two_sided']}"
f" mean={stats['mean_back_two_sided']}")
print(f" 负例(不取反): covered={stats['back_camera']['covered_no_flip']}"
f" mean={stats['mean_back_no_flip']}")
print(f"sha256 取反 : {stats['sha256_back_two_sided']}")
print(f"sha256 不取反 : {stats['sha256_back_no_flip']} 差异像素 {stats['two_sided_vs_no_flip_diff']}")
print(f"sha256 只正面 : {stats['sha256_back_single']} 差异像素 {stats['two_sided_vs_single_diff']}")
print(f"正面 vs 背面双面: 差异像素 {stats['front_vs_back_two_sided_diff']}"
f" (mean 正面 {stats['mean_front']} vs 背面双面 {stats['mean_back_two_sided']})")
print(f"不取反 == 正面左右镜像 ? {stats['no_flip_is_front_mirrored']}")
print(f"两遍渲染 : sha256={stats['sha256_back_two_pass']}"
f" 与单趟逐字节相同 ? {stats['two_pass_equals_single_pass']}")
return 0
if __name__ == "__main__":
sys.exit(main())
跑一次:
# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_raster_09_3_two_sided.py
布帘 : 4x4 格 -> 32 个三角形(开曲面,无法线属性)
正面相机 : front=32 back=0 covered=7056
背面相机 : front=0 back=32
只画正面 : covered=0 culled=32
双面(取反) : covered=7056 mean=24.7122
负例(不取反): covered=7056 mean=73.7122
sha256 取反 : a89436d96ee4447c191705146b0ddb6a378d6eb5612bb50e49cc673da062b4bb
sha256 不取反 : aa477018ca07e52429cf2a7988248953abd1757986dd81e36e9d2aaea22a2a5e 差异像素 7056
sha256 只正面 : 3d0ae4dea1a1ae6d8eb29f4425c4a73469a2ac24cd56565b85bafb12b0bd0dc7 差异像素 7056
正面 vs 背面双面: 差异像素 7056 (mean 正面 73.7122 vs 背面双面 24.7122)
不取反 == 正面左右镜像 ? True
两遍渲染 : sha256=a89436d96ee4447c191705146b0ddb6a378d6eb5612bb50e49cc673da062b4bb 与单趟逐字节相同 ? True
三个结果,一个指纹
只画正面:覆盖 0 像素、剔除 32 个。 这是「封闭性假设」被打破时的标准症状——整块布帘在背面视角下被剔得干干净净,画面只剩背景色。
双面(取反):覆盖 7056 像素,平均通道值 24.71。 布帘重新出现。它偏暗是正确的:光源在 +z 一侧,照的是布帘的正面(法线 +z),我们从背面(-z 侧)看,看到的这一面本就背对光源,只有环境光。
负例(不取反):覆盖同样是 7056,但平均通道值 73.71。 忘了取反时,背面被当成正面照亮,于是布帘亮得和正面一样。更漂亮的是它的指纹:实测 不取反 == 正面左右镜像 为真——两幅图像逐字节只差一个左右翻转。原因是两种视角只差一个相机位置,x 轴被镜像,而着色用的法线都是同一个 +z。如果你的双面材质渲染出来「背面和正面长得一样」,那基本就是漏了取反。
两遍渲染是等价的
双面材质还有一种经典实现:两遍渲染——第一遍剔除正面、只画背面(法线取反),第二遍剔除背面、只画正面,深度缓冲决定谁在前。实测它与单趟实现的像素缓冲逐字节相同(两遍渲染 ... 与单趟逐字节相同 ? True,两者 sha256 都是 a89436d9…)。这是必然的:单趟只是把两遍的三角形按同一个深度测试合并在一次遍历里,而深度测试的结果与遍历顺序无关。
跨语言核对
P 层从同一份配置重算背面相机的双面渲染,与 S 层落盘的 curtain_back.rgb 逐字节比对;两个负例(不取反、只画正面)也必须复现各自的 sha256:
// snippet: core/test/cg_raster_09_3_two_sided.test.ts(节选)
assert.equal(sha(backFlip.result.rgb), sha(new Uint8Array(backRgb))); // 与 Python 侧逐字节相同
// 负例一:背面法线不取反 —— 背面被当成正面照亮,必须被拒绝
assert.notEqual(sha(backNoFlip.result.rgb), sha(backFlip.result.rgb));
assert.equal(sha(backNoFlip.result.rgb), sha(mirrorX(front.result.rgb))); // 法线没翻的指纹
// 负例二:只画正面 —— 背面视角下 32 个三角形全被剔除
assert.equal(backSingle.result.culled, 32);
assert.equal(backSingle.result.coveredCount, 0);
三张图(取反、不取反、只画正面)落在同一份 out/cg_raster_09_3_two_sided.json 里,每条断言都拿真实 sha256 做基准,而不是「看起来差不多」。
这组数字各是什么
32 / 0 与 0 / 32 是两台相机下同一块布帘的朝向统计:正面相机全是正面,背面相机全是背面。开曲面的朝向完全由观察方向决定。
覆盖 0 / 剔除 32 是单面渲染在背面视角下的结果——封闭性假设失效的直接后果。
24.71 vs 73.71 是取反与不取反的平均通道值:取反后背面正确处于背光侧(暗),不取反则被误当正面(亮)。
「不取反 == 正面左右镜像」为真是本节最锋利的判据:它把「漏翻法线」这个 bug 变成了一个可逐字节验证的恒等式。
两个 sha256 相同(单趟与两遍)说明两种双面实现等价,深度测试的结果与遍历顺序无关。
阅读导航:上一节:9.2 剔除的判定与代价 · 下一节:10.1 深度缓冲的精度分布 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。