6.2 重心坐标与插值
6.1 已经把「像素在不在三角形里」变成了三个边函数同不同号的问题。但覆盖判定只是「涂不涂」,真正让画面有内容的是涂什么:颜色、纹理坐标、法线、深度,都要从一个像素的位置算出来。本节回答这个问题,用的还是那三个边函数——把它们除以三角形的面积,就得到重心坐标。
重心坐标是整条管线里最「物超所值」的工具:它把「这个像素离三个顶点各有多近」编码成三个比例,任何在三角形上线性变化的量都能用它插值出来。颜色、深度、UV、法线、雾因子,全是一样的公式。理解一个,就理解全部。
重心坐标就是归一化的边函数
上一节定义过边函数 $E(A,B,P)$。把三角形的三条边各取一次边函数,得到三个量:
$$w_0 = E(B,C,P), \quad w_1 = E(C,A,P), \quad w_2 = E(A,B,P)$$
对同一个三角形,$w_0+w_1+w_2$ 恒等于三角形有向面积的 2 倍(记为 area)。于是三个重心权重就是
$$\lambda_0 = \frac{w_0}{\text{area}}, \quad \lambda_1 = \frac{w_1}{\text{area}}, \quad \lambda_2 = \frac{w_2}{\text{area}}$$
它们满足 $\lambda_0+\lambda_1+\lambda_2=1$,且 $P=\lambda_0 A+\lambda_1 B+\lambda_2 C$。这正是「重心坐标」名字的由来:$P$ 是三个顶点的凸组合。覆盖判据 $\lambda_i \ge 0$ 与上一节的「三个边函数同号」是同一件事,只是多了个归一化。
在本卷的定点实现里,我们不做除法:始终保留整数 $w_i$ 和整数 area,只在最后一步用「乘完再整除」的方式插值。这样归一化误差被推迟到最后一次运算,中间不累积。顶点处权重自然退化为 $(area,0,0)$:
顶点 0 处权重 = [696320, 0, 0]
顶点 1 处权重 = [0, 696320, 0]
顶点 2 处权重 = [0, 0, 696320]
在顶点 $A$ 处,边 $B$-$C$ 的边函数取到满值 area,另外两个边函数因为 $P$ 落在对应边上而等于 0。这是重心坐标最干净的边界情形。
不变量:w0 + w1 + w2 恒等于 area
上面对每个覆盖像素都算了 $w_0+w_1+w_2$,并与 area 相减。实测结果:
不变量 w0+w1+w2 : max|residual| = 0 sum(residual) = 0
残差严格为 0,不是「接近 0」,也不是「小于某个 epsilon」。原因很朴素:三个边函数展开后,$P$ 的坐标项两两抵消,只剩与 $P$ 无关的常数项,那个常数恰好就是 area。这是恒等式,在整数域里也成立——只要每个边函数各自没溢出。
这条不变量是定点光栅化的一道免费自检:任何时刻只要 $w_0+w_1+w_2 \ne \text{area}$,就说明边函数实现或包围盒裁剪出了错。本卷的共享内核 cglib.rasterize 内部也依赖这条不变量来定位 bug。它比「和约等于 1」这种浮点判据强得多——后者总得挑一个 epsilon,而这里 epsilon 是 0。
用权重插值颜色与深度
设三个顶点上分别有颜色 $C_0,C_1,C_2$ 与深度 $D_0,D_1,D_2$。像素 $(px,py)$ 的插值结果是
$$C = \frac{w_0 C_0 + w_1 C_1 + w_2 C_2}{\text{area}}, \qquad D = \frac{w_0 D_0 + w_1 D_1 + w_2 D_2}{\text{area}}$$
颜色逐通道做,深度做一次。因为分子分母都是整数,这一步可以完全在整数域完成。取三个采样点看实测:
( 6, 5) w=[528384, 49152, 118784] sum=696320 residual=0 rgb=[181, 71, 87] z=13492
(12, 5) w=[307200, 368640, 20480] sum=696320 residual=0 rgb=[130, 144, 64] z=19275
(12,10) w=[81920, 307200, 307200] sum=696320 residual=0 rgb=[78, 130, 130] z=43369
三角形顶点色是红 $(220,60,60)$、绿 $(60,220,60)$、蓝 $(60,60,220)$。像素 $(12,10)$ 的权重 $(81920, 307200, 307200)$ 里绿蓝各占一半多一点,插出来的颜色就是偏青的 $(78,130,130)$——红分量被压到很低,绿蓝拉平。深度同理:$(6,5)$ 靠近顶点 $A$(深度 0),$z=13492$ 最小;$(12,10)$ 靠近深端,$z=43369$ 最大。所有值都落在 $[0, \text{ONE}]$ 内,符合 Q16.16 深度约定。
注意深度这里用的是屏幕空间线性插值。真实的透视投影下,深度与颜色在屏幕空间都不是线性的——它们要除以透视修正因子 $w$。本节刻意先讲屏幕空间线性版本,因为它是定点内核的基础;透视修正插值留到第 11 章,那时你会看到它不过是在这套权重上多乘一个 1/w 系数。
// 与截断:为什么分子非负时二者同义
插值那一步写的是整除。Python 里 // 是向下取整(floor),负数时 $-7 // 2 = -4$;而 C、Rust、JS 的整数除法是向零截断(trunc),$-7 / 2 = -3$。两者只在负数上分道扬镳。
本节的分子会为负吗?$w_i \ge 0$(覆盖判据保证)、颜色与深度 $\ge 0$,所以每一项 $w_i v_i \ge 0$,求和也 $\ge 0$,area 又 $> 0$。分子分母都非负,此时 floor 与 trunc 完全一致:
最小权重 : 0(>= 0 保证分子非负,`//` 与截断一致)
于是 Python 的 //、TypeScript 的 Math.floor、C 的 / 在这里给出同一个整数。这不是巧合,是本节把「先判覆盖、再插值」的顺序固定下来所换来的性质——只要保证插值只发生在被覆盖的像素上,分子就永远非负。跨语言逐位一致的前提就藏在这一行。
可运行产物:一张插值图与它的指纹
下面的脚本渲染一个顶点带色、带深度的三角形,把插值结果写成像素缓冲,并对缓冲算 sha256。这个指纹是 P 层跨语言断言的基准。
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_06_2_bary.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_06_2_bary.py
"""6.2 重心坐标与插值:三个边函数的归一化 + 定点不变量。
断言:w0 + w1 + w2 == area 恒成立(残差严格为 0);顶点处权重退化为 (area,0,0) 等;
颜色 / 深度按 w_i * v_i 求和后整除 area,分子非负故 `//` 与截断一致。
产物:out/cg_raster_06_2_bary.rgb、out/cg_raster_06_2_bary.png、out/cg_raster_06_2_bary.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
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
W, H, BG = 24, 20, (0, 0, 0)
SUB1, HALF, ONE = cglib.SUB1, cglib.SUB1 // 2, cglib.ONE
TRI = ((5 * SUB1, 3 * SUB1), (19 * SUB1, 7 * SUB1), (8 * SUB1, 16 * SUB1))
COLORS = ((220, 60, 60), (60, 220, 60), (60, 60, 220))
DEPTHS = (0, ONE // 2, ONE)
def weights(tri, X, Y):
"""三条边对应的重心权重 (w0, w1, w2),与 cglib.rasterize 逐字对齐。"""
(x0, y0), (x1, y1), (x2, y2) = tri
return ((x2 - x1) * (Y - y1) - (y2 - y1) * (X - x1),
(x0 - x2) * (Y - y2) - (y0 - y2) * (X - x2),
(x1 - x0) * (Y - y0) - (y1 - y0) * (X - x0))
(x0, y0), (x1, y1), (x2, y2) = TRI
area = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0)
rgb = np.zeros((H, W, 3), dtype=np.int64)
rgb[:, :] = BG
covered = max_residual = sum_residual = 0
min_w = None
samples = []
for py in range(H):
for px in range(W):
w0, w1, w2 = weights(TRI, px * SUB1 + HALF, py * SUB1 + HALF)
if w0 < 0 or w1 < 0 or w2 < 0:
continue
covered += 1
r = w0 + w1 + w2 - area
sum_residual += r
max_residual = max(max_residual, abs(r))
min_w = min(w0, w1, w2) if min_w is None else min(min_w, w0, w1, w2)
# 分子非负(w >= 0、颜色/深度 >= 0)故 `//` 与截断一致
z = (w0 * DEPTHS[0] + w1 * DEPTHS[1] + w2 * DEPTHS[2]) // area
for ch in range(3):
rgb[py, px, ch] = (w0 * COLORS[0][ch] + w1 * COLORS[1][ch] + w2 * COLORS[2][ch]) // area
if px in (6, 12, 18) and py in (5, 10, 15):
samples.append({"px": px, "py": py, "w": [w0, w1, w2], "sum": w0 + w1 + w2,
"residual": r, "rgb": [int(v) for v in rgb[py, px]], "z": z})
px8 = np.clip(rgb, 0, 255).astype(np.uint8)
out = os.path.join(ROOT, "out")
png = cglib.save_png(os.path.join(out, "cg_raster_06_2_bary.png"), rgb)
with open(os.path.join(out, "cg_raster_06_2_bary.rgb"), "wb") as fh:
fh.write(px8.tobytes())
digest = hashlib.sha256(px8.tobytes()).hexdigest()
vert = [{"vertex": i, "w": list(weights(TRI, v[0], v[1]))} for i, v in enumerate(TRI)]
with open(os.path.join(out, "cg_raster_06_2_bary.json"), "w", encoding="utf-8") as fh:
json.dump({"canvas": [W, H], "triangle": [list(v) for v in TRI], "colors": [list(c) for c in COLORS],
"depths": list(DEPTHS), "area2x": area, "covered": covered,
"max_residual": max_residual, "sum_residual": sum_residual, "min_weight": min_w,
"vertex_weights": vert, "samples": samples, "sha256": digest}, fh,
ensure_ascii=False, indent=2)
print(f"triangle : A={TRI[0]} B={TRI[1]} C={TRI[2]}")
print(f"area2x : {area};covered {covered} 像素")
print(f"不变量 w0+w1+w2 : max|residual| = {max_residual} sum(residual) = {sum_residual}")
print(f"最小权重 : {min_w}(>= 0 保证分子非负,`//` 与截断一致)")
for v in vert:
print(f" 顶点 {v['vertex']} 处权重 = {v['w']}")
for s in samples:
print(f" ({s['px']:2d},{s['py']:2d}) w={s['w']} sum={s['sum']} residual={s['residual']} rgb={s['rgb']} z={s['z']}")
print(f"sha256(rgb) : {digest}")
print(f"png : {png} bytes")
sha256(rgb) 是对像素缓冲本身算的,不是对 PNG 容器——PNG 编码器、压缩级别、时间戳都会影响容器字节,但对原始 RGB 字节毫无影响。跨语言比较必须锁定到这一层。
跨语言断言:逐字节相同的像素缓冲
TypeScript 内核用 Number 重写权重与插值($\le 2^{22}$,浮点整数区精确),再用 node --test 把 S 层的 JSON 与 .rgb 逐条对上。
// file: lantern/core/src/lantern/cg_raster_06_bary.ts
// tier: P
// verify: node --test 'core/test/cg_raster_06_*.test.ts'
// 6.2 的重心插值内核:与 Python 侧 sim/cg_raster_06_2_bary.py 逐字对齐。
//
// 三个权重是三条边的边函数,恒有 w0 + w1 + w2 == area(残差恒为 0)。
// 颜色 / 深度按 w_i * v_i 求和后整除 area;分子非负(w_i >= 0、v_i >= 0),
// 故 Math.floor(num / area) 与 Python 的 `num // area` 同义 —— 跨语言逐位一致的前提。
import { HALF, SUB1, edge, signedArea2x, type XY } from "./cg_raster_06_edge.ts";
export type Bary = { w0: number; w1: number; w2: number };
/** 三条边对应的重心权重 (w0, w1, w2)。 */
export function weights(xy: XY[], X: number, Y: number): Bary {
const a = xy[0];
const b = xy[1];
const c = xy[2];
return {
w0: edge(b[0], b[1], c[0], c[1], X, Y),
w1: edge(c[0], c[1], a[0], a[1], X, Y),
w2: edge(a[0], a[1], b[0], b[1], X, Y),
};
}
export type InterpResult = {
rgb: Uint8Array;
depth: Int32Array;
covered: number;
maxResidual: number;
};
/** 逐像素重心插值:颜色 8 位、深度 Q16.16,未覆盖处深度记 -1。 */
export function interpolateTriangle(
xy: XY[],
colors: number[][],
depths: number[],
w: number,
h: number,
): InterpResult {
const rgb = new Uint8Array(w * h * 3);
const depth = new Int32Array(w * h).fill(-1);
const area = signedArea2x(xy);
let covered = 0;
let maxResidual = 0;
if (area <= 0) return { rgb, depth, covered, maxResidual };
const xs = [xy[0][0], xy[1][0], xy[2][0]];
const ys = [xy[0][1], xy[1][1], xy[2][1]];
const minx = Math.max(0, Math.floor(Math.min(xs[0], xs[1], xs[2]) / SUB1));
const maxx = Math.min(w - 1, Math.floor(Math.max(xs[0], xs[1], xs[2]) / SUB1));
const miny = Math.max(0, Math.floor(Math.min(ys[0], ys[1], ys[2]) / SUB1));
const maxy = Math.min(h - 1, Math.floor(Math.max(ys[0], ys[1], ys[2]) / SUB1));
for (let py = miny; py <= maxy; py += 1) {
const Y = py * SUB1 + HALF;
for (let px = minx; px <= maxx; px += 1) {
const X = px * SUB1 + HALF;
const { w0, w1, w2 } = weights(xy, X, Y);
if (w0 < 0 || w1 < 0 || w2 < 0) continue;
covered += 1;
const r = w0 + w1 + w2 - area;
if (Math.abs(r) > maxResidual) maxResidual = Math.abs(r);
const idx = py * w + px;
depth[idx] = Math.floor((w0 * depths[0] + w1 * depths[1] + w2 * depths[2]) / area);
const o = idx * 3;
for (let ch = 0; ch < 3; ch += 1) {
rgb[o + ch] = Math.floor((w0 * colors[0][ch] + w1 * colors[1][ch] + w2 * colors[2][ch]) / area);
}
}
}
return { rgb, depth, covered, maxResidual };
}
node --test 'core/test/cg_raster_06_2_bary.test.ts' 的实测输出:
✔ 重心权重和恒等于面积:残差严格为 0
✔ 顶点处权重退化为 (area,0,0)/(0,area,0)/(0,0,area)
✔ 【核心】插值像素缓冲 sha256 与 Python 侧逐字节相同
✔ 采样点的插值颜色与深度与 Python 侧一致
ℹ tests 4 ℹ pass 4 ℹ fail 0
核心那条断言比对的是 sha256 与 .rgb 的逐字节:TypeScript 算出的缓冲与 Python 侧 db16d72e... 完全一致。这里踩过一个坑值得记下:顶点处 TypeScript 可能得到 -0(Python 是 0)。-0 === 0 为真,但 node:assert/strict 底层的 Object.is(-0, 0) 为假,用 assert.equal 会莫名失败。改成 assert.ok(got === want) 即可——符号位不影响后续乘加,但断言库分得清。
下一节把这套权重用到深度上,看多个三角形争夺同一个像素时,谁的颜色该留下。
阅读导航:上一节:6.1 边函数与覆盖判定 · 下一节:6.3 深度插值与 z-buffer 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。