4.1 硬阴影与阴影光线

硬阴影只有「被挡住」和「没被挡住」两种结果,判据是一条从命中点指向光源的阴影光线。本节把 `off` 是沿法线的偏移向量、不是起点这件事讲透:写错一行,77211 条阴影光线的遮挡数会从 1357 涨到 77210,画面只剩环境光。最后用一张方向光掩码数出像素层面的真阴影 839 个。

4.1 硬阴影与阴影光线

站内 /graphics-water-ocean-rendering/ 那篇做的是水面渲染工程:解析波形、菲涅尔近似、屏幕空间反射,目标是把一片海画得像海,关心帧时间与观感。本章换一条路——把反射与折射当成物理与数值问题,逐个方向公式在定点整数下算清楚。不过在谈折射之前,先得解决一个更基础、也更「离散」的问题:一个点到底有没有被挡住。这就是硬阴影。

可见性只有 0 和 1

直接光照的每一项都要乘一个可见性因子 V(p, l)。硬阴影把它取成二值:从命中点 p 到光源 l 的线段上如果没有别的几何,V = 1;只要有一个三角形挡在中间,V = 0。没有半影、没有渐变——这正是「硬」的含义,也是它便宜的原因:判据是一条光线,答案是「命中」或「不命中」。

在卷三的底座里,这条光线叫阴影光线(shadow ray)。它不是主光线,也不参与成片,只在着色阶段被发出来问一句「挡不挡」。本节的全部工作就是把它数清楚:

  • 发了多少条?(77211)
  • 其中多少条真的被挡住了?(1357)
  • 换一个错误的起点写法,这两个数会变成什么?(77211 / 77210)

场景沿用 config/scene_spec.json:487 顶点、896 三角形、320×240,相机在 (0, 6, 24),一盏方向光加一盏点光源,环境光 0.2。每个命中点最多发两条阴影光线——一条朝方向光,一条朝点光源。

off 是偏移向量,不是起点

底座的着色器签名是 shade(p, off, n, albedo, depth)。p 是命中点,n 是已经朝向入射光线的插值法线。第二个参数 off 很容易被误读:它是沿法线的偏移向量,模长恒为 EPSILON = 64(世界单位 1/1024),作用是让阴影光线从表面「抬起来」一点点,避开自交。它绝不是阴影光线的起点。

# snippet: sim/cg_trace_04_light.py(节选:起点这一行)
# verify: /opt/homebrew/bin/python3 sim/cg_trace_04_1_hard_shadow.py
        # 阴影光线的起点 = 命中点 + 沿法线的偏移(`off` 是偏移向量,不是起点)
        so = off if off_is_origin else cglib.v_add(p, off)

一行之差,两个世界。v_add(p, off) 才是起点。下面会把「写成 off」的后果用整数摆出来——这不是假想的坑,是这一节真正踩过并修掉的那个 bug。

数清楚:77211 条阴影光线、1357 次遮挡

S 层脚本把正确实现、错误实现、以及一张方向光阴影掩码各渲一遍,计数全部落到 JSON。

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_04_1_hard_shadow.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_04_1_hard_shadow.py
"""4.1《硬阴影与阴影光线》:阴影光线的起点、遮挡计数与一个真实的起点错误。

本节只做一件事:把「这个点在不在阴影里」还原成一条从命中点出发、朝光源方向的光线,
并用整数把它的条数与遮挡数数清楚。场景是 `config/scene_spec.json`(487 顶点 /
896 三角形 / 320x240),直接光照 = 环境光 + 方向光 + 点光源。

三个量:

  1. **正确实现**:阴影光线起点 = `v_add(p, off)`(`off` 是沿法线的偏移**向量**,
     模长 `EPSILON`)。实测 77211 条阴影光线,遮挡 1357 条,像素缓冲 sha256 与卷三
     基线逐字节相同。
  2. **错误实现**:把 `off` 当**起点**(`so = off`)。光线从世界原点附近发出并立刻
     自交,遮挡数从 1357 跳到 77210(999.987‰),画面只剩环境光。
  3. **阴影掩码**:单独渲一张「方向光是否被遮挡」的二值图,数出真正落在阴影里的像素
     ——它远小于遮挡光线数,因为一条主光线最多发两条阴影光线。

产出:
  out/cg_trace_04_1_shadow.json     计数与 sha256(供正文引用与 P 层对拍)
  out/cg_trace_04_1_shadowmask.png  方向光阴影掩码(255 阴影 / 0 受光 / 64 背光)
"""

import json
import os
import sys
import time

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 cgtrace  # noqa: E402
import cg_trace_04_light as light  # noqa: E402

OUT = os.path.join(ROOT, "out")
MAX_DEPTH = 1
KEYS = ["dir_rays", "dir_hits", "pt_rays", "pt_hits"]


def make_shader(ctx, off_is_origin=False):
    """直接光照 + 硬阴影,复用第 4 章共用的求值器。

    `off_is_origin=True` 复现那个真实踩过的坑:把 `off` 当成阴影光线起点,而不是
    「命中点 + 偏移」。两条分支只差一行,计数差一个量级。
    """
    spec = ctx["spec"]
    tris, _ = cgtrace.build_scene_tris(spec)
    nodes, order = cgtrace.build_bvh(tris)
    ctx["tris"] = tris
    ctx["bvh"] = (nodes, order)
    return light.build_direct(spec, nodes, order, tris, ctx["tmax"],
                              ctx["stats"], ctx["bvh_stats"], off_is_origin)


def make_mask_shader(ctx):
    """方向光阴影掩码:255 = 被遮挡(真阴影)、0 = 受光、64 = 背光(法线背对光源)。"""
    spec = ctx["spec"]
    tris, _ = cgtrace.build_scene_tris(spec)
    nodes, order = cgtrace.build_bvh(tris)
    ctx["tris"] = tris
    ctx["bvh"] = (nodes, order)
    ldir = tuple(spec["directionalLight"]["dirQ16"])
    tmax, stats, bvh_stats = ctx["tmax"], ctx["stats"], ctx["bvh_stats"]

    def shade(p, off, n, albedo, depth):
        so = cglib.v_add(p, off)
        if cglib.v_dot(n, ldir) <= 0:
            stats["back"] += 1
            return (64, 64, 64)
        if cgtrace.bvh_intersect(nodes, order, tris, so, ldir, tmax, bvh_stats) is None:
            stats["lit"] += 1
            return (0, 0, 0)
        stats["shadow"] += 1
        return (255, 255, 255)

    return shade


def render(factory, stats_keys):
    spec = cglib.load_spec()
    ctx = {"stats": {k: 0 for k in stats_keys}}
    t0 = time.time()
    color, stats = cgtrace.render_trace(spec, spec["width"], spec["height"],
                                        MAX_DEPTH, factory, ctx)
    ms = int((time.time() - t0) * 1000)
    return spec, color, stats, ctx["stats"], ms, len(ctx["tris"])


def hit_pixels(color, spec):
    """命中像素数:与背景色不同的像素。"""
    bg = tuple(spec["backgroundQ8"])
    mask = ~((color[:, :, 0] == bg[0]) & (color[:, :, 1] == bg[1])
             & (color[:, :, 2] == bg[2]))
    return int(mask.sum())


def main():
    os.makedirs(OUT, exist_ok=True)
    spec, ok_color, ok_stats, ok_s, ok_ms, n_tris = render(
        lambda c: make_shader(c, False), KEYS)
    _, bad_color, _, bad_s, bad_ms, _ = render(lambda c: make_shader(c, True), KEYS)
    _, mask_color, _, mask_s, mask_ms, _ = render(make_mask_shader, ["lit", "shadow", "back"])

    ok_sha = cgtrace.digest(ok_color)
    bad_sha = cgtrace.digest(bad_color)
    hit_px = hit_pixels(ok_color, spec)
    shadow_rays = ok_s["dir_rays"] + ok_s["pt_rays"]
    shadow_hits = ok_s["dir_hits"] + ok_s["pt_hits"]
    bad_rays = bad_s["dir_rays"] + bad_s["pt_rays"]
    bad_hits = bad_s["dir_hits"] + bad_s["pt_hits"]

    cglib.save_png(os.path.join(OUT, "cg_trace_04_1_shadowmask.png"), mask_color)
    with open(os.path.join(OUT, "cg_trace_04_1_shadow_ok.rgb"), "wb") as f:
        f.write(np.ascontiguousarray(ok_color, dtype=np.uint8).tobytes())

    report = {
        "canvas": [spec["width"], spec["height"]],
        "triangles": n_tris,
        "primary_rays": ok_stats["rays"],
        "hit_pixels": hit_px,
        "shadow_rays": shadow_rays,
        "shadow_hits": shadow_hits,
        "shadow_hit_permille": round(1000.0 * shadow_hits / shadow_rays, 4),
        "dir_rays": ok_s["dir_rays"], "dir_hits": ok_s["dir_hits"],
        "pt_rays": ok_s["pt_rays"], "pt_hits": ok_s["pt_hits"],
        "shadow_rays_per_hit_px": round(shadow_rays / hit_px, 4),
        "mask_lit": mask_s["lit"], "mask_shadow": mask_s["shadow"], "mask_back": mask_s["back"],
        "ok_sha256": ok_sha,
        "buggy_shadow_rays": bad_rays,
        "buggy_shadow_hits": bad_hits,
        "buggy_permille": round(1000.0 * bad_hits / bad_rays, 4),
        "buggy_sha256": bad_sha,
        "mae_ok_vs_buggy": round(cgtrace.mae(ok_color, bad_color), 4),
        "psnr_ok_vs_buggy": round(cgtrace.psnr(ok_color, bad_color), 4),
        "ms_ok": ok_ms, "ms_buggy": bad_ms, "ms_mask": mask_ms,
    }
    with open(os.path.join(OUT, "cg_trace_04_1_shadow.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
        f.write("\n")

    print("== 4.1 硬阴影与阴影光线 ==")
    print(f"主光线            : {ok_stats['rays']}")
    print(f"命中像素          : {hit_px}")
    print(f"阴影光线          : {shadow_rays}(方向光 {ok_s['dir_rays']} + 点光 {ok_s['pt_rays']})")
    print(f"遮挡              : {shadow_hits}(方向光 {ok_s['dir_hits']} + 点光 {ok_s['pt_hits']},"
          f"{report['shadow_hit_permille']}‰)")
    print(f"阴影掩码          : 受光 {mask_s['lit']} / 阴影 {mask_s['shadow']} / 背光 {mask_s['back']}")
    print(f"正确 sha256       : {ok_sha}")
    print(f"错误(off 当起点)  : 光线 {bad_rays} 遮挡 {bad_hits}({report['buggy_permille']}‰)")
    print(f"两者 MAE / PSNR   : {report['mae_ok_vs_buggy']} / {report['psnr_ok_vs_buggy']} dB")
    print(f"耗时              : ok {ok_ms} ms / buggy {bad_ms} ms / mask {mask_ms} ms")
    return 0


if __name__ == "__main__":
    sys.exit(main())

跑一遍:

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_04_1_hard_shadow.py
== 4.1 硬阴影与阴影光线 ==
主光线            : 76800
命中像素          : 43731
阴影光线          : 77211(方向光 43250 + 点光 33961)
遮挡              : 1357(方向光 839 + 点光 518,17.5752‰)
阴影掩码          : 受光 42411 / 阴影 839 / 背光 481
正确 sha256       : 213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71
错误(off 当起点)  : 光线 77211 遮挡 77210(999.987‰)
两者 MAE / PSNR   : 40.0972 / 16.0685 dB
耗时              : ok 6827 ms / buggy 6294 ms / mask 5016 ms

这张表里有几个数字值得停一下:

  • 主光线 76800 = 320 × 240,一条不多一条不少。
  • 命中像素 43731(569.4141‰),与卷三基线一致。
  • 阴影光线 77211,平均每个命中像素 1.7656 条。它小于 2 × 43731 = 87462,因为背对光源的方向(n·l ≤ 0)根本不该发这条光线——省掉的那些正是「背光面」。
  • 正确实现的 sha256 与卷三基线逐字节相同(213b4130…)。这说明本节复现的不是一个「长得像」的直接光照,而是同一条链路。
  • 方向光 43250 + 点光 33961 = 77211;遮挡 839 + 518 = 1357。两条光源的遮挡率差别很大(1.94% 对 1.53%),因为点光源在 (5, 8, 5),方向不同,被地面挡住的区域也不同。

一个真实的起点错误

把 so = v_add(p, off) 写成 so = off,会发生什么?

off 的模长是 EPSILON,方向沿法线。它作为向量是对的,但作为世界坐标点,它就落在离原点不到 0.001 个世界单位的地方。于是 77211 条阴影光线全部从世界原点附近发出,朝着各自光源的方向射出去。而场景的地形正是以原点为中心铺开的——这些光线一出膛就扎进地面,几乎每一条都立刻自交。

结果就是 JSON 里的那一对数字:

量正确错误(off 当起点)
阴影光线7721177211
遮挡135777210
遮挡率17.5752‰999.987‰
帧 sha256213b4130…14823473…
与正确解的 MAE—40.0972

光线总数一条不变——因为「发不发」只取决于 n·l,与起点无关。变的是「挡不挡」:遮挡率从 1.76% 涨到 99.999%。有且仅有一条光线逃过了自交(77211 − 77210 = 1),那是它恰好从原点朝天空的方向射出。

后果直接写在画面上:所有命中点都判定为「完全被挡」,方向光与点光源的贡献同时归零,只剩下环境光。整幅图的平均亮度塌下来,与正确解的 MAE 达到 40.10(8 位通道,满量程 255),PSNR 只有 16.07 dB。

这就是为什么这一节值得单独讲:硬阴影的逻辑是平凡的,但起点是容易错的。而这个错误不会崩溃、不会报错,只会静默地让画面变黑——一个只能靠计数和逐字节判据抓出来的 bug。

阴影掩码:像素层面的真阴影

「1357 次遮挡」是光线层面的数。像素层面有多少个点真的落在阴影里?S 层单独渲了一张方向光掩码:每个命中点只问一次「方向光挡不挡」,被挡涂 255、受光涂 0、背光(n·l ≤ 0)涂 64。数出来是

  • 受光 42411
  • 阴影 839
  • 背光 481

注意 42411 + 839 + 481 = 43731,正好等于命中像素数——掩码没有漏掉任何一个像素,也没有重复计数。而方向光的阴影像素数 839 与光线层面的 dir_hits = 839 完全相等,因为方向光的阴影光线正是每个受光侧像素各发一条。

P 层把计数钉死

P 层用 TypeScript 重写同一条链路,全程 BigInt,断言对象是颜色缓冲的 sha256 与四个计数器:

# snippet: core/test/cg_trace_04_1_hard_shadow.test.ts(节选:正例与负例)
# verify: node --test 'core/test/cg_trace_04_1_hard_shadow.test.ts'
    assert.equal(counts.dirRays + counts.ptRays, golden.shadow_rays);
    assert.equal(counts.dirHits + counts.ptHits, golden.shadow_hits);
  // 负例:把 off 当起点,光线条数不变但几乎全部自交
  assert.equal(counts.dirRays + counts.ptRays, golden.buggy_shadow_rays);
  assert.equal(sha(rgb), golden.buggy_sha256, "错的起点必须让逐字节判据分叉");

第三组断言是这一节的判据核心:正例的 sha256 必须相同,负例的 sha256 必须不同。只测正例是不够的——一个把 off 当起点的实现,如果只对「画面是不是黑的」做模糊判断,很可能蒙混过关;而逐字节判据不允许。

小结

  • 硬阴影的判据是一条阴影光线,起点是 v_add(p, off),off 是偏移向量。
  • 本节场景实测:主光线 76800、命中像素 43731、阴影光线 77211、遮挡 1357。
  • 把 off 当起点,光线条数不变但遮挡率从 1.76% 涨到 99.999%,画面只剩环境光。
  • 像素层面的方向光阴影掩码给出 839 个真阴影像素,与光线层面的 dir_hits 相等。

下一节开始谈光线被挡住之后的事:它会往哪里去。

阅读导航:上一节:3.3 BVH 的遍历与代价 · 下一节:4.2 镜面反射与折射 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配