6.2 重心坐标与插值

把 6.1 的三条边函数归一化就得到重心坐标:权重 w0/w1/w2 是像素对三个顶点的贡献比例。本节证明不变量 w0+w1+w2 恒等于面积(定点残差严格为 0),用权重线性插值颜色与深度,说清分子非负时 Python 的 `//` 与截断除同义,最后让 TypeScript 复现出逐字节相同的像素缓冲。

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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 14.3 中间缓冲的可视化
  2. 14.2 覆盖率与线框
  3. 14.1 深度图与法线图