← 都来码 / Dolaima← 例程索引c/3d/pbr_demo.c原文

c/3d/pbr_demo.c · 35635 字节 · 下载原文

/* pbr_demo.c —— **3D 渲染能力第二批**(PBR:金属度-粗糙度)示例 + 判据。
 * pbr_demo.c — **second batch of 3D render features** (PBR: metallic-roughness): example and criteria.
 *
 * ## 覆盖两样(按序号 `#652`–`#653`)
 * ## This file covers two things (numbers #652-#653)
 *
 *   ① `ui_3d_viewport_set_pbr(vp, on)`             `#652` —— 视口级 PBR 总开关
 *   1) `ui_3d_viewport_set_pbr(vp, on)`            `#652` - per-viewport PBR master switch
 *   ② `ui_3d_obj_set_pbr(obj, metallic, rough)`    `#653` —— 逐物体参数
 *   2) `ui_3d_obj_set_pbr(obj, metallic, rough)`   `#653` - per-object parameters
 *
 * ## ⚠⚠ 默认关,而这个文件必须能证明「关着时真的没动」
 * ## ⚠⚠ Off by default, and this file must prove "off really means untouched"
 *
 * 本仓对老程序有一条硬保证:只调过 `#612` 的程序画面**逐位不变**。
 * The engine guarantees old programs are **bit-identical**.
 * PBR 会改**每一个像素**(它换掉整条光照方程)⇒ 这一批同样"开新号 + 默认关"。
 * PBR changes **every pixel** (it replaces the entire lighting equation), so this batch is
 * "new numbers + off by default" too.
 * 而每段判据都配一条**反向**断言:设回默认 ⇒ 读数必须逐位回到原样。
 * Every section carries a **reverse** assertion: back to the default and the reading returns bit for bit.
 *
 * ## 为什么判据挑的是这些量(而不是"有没有那个接口")
 * ## Why these particular measurements (and not "does the entry point exist")
 *
 *   · **金属 vs 电介质**:不是"金属更亮"(那可以靠乱加一项做到),而是
 *   · **metal vs dielectric**: not "metal is brighter" (that could be faked by adding any term), but
 *     **高光带不带底色** —— 判据取"高光中心减同一球上稍远处"这个**增量**,
 *     whether the **highlight carries the base tint** - the criterion takes the **increment**
 *     (highlight centre minus a point a little further out on the **same** sphere),
 *     再看它的 R/G 差:金属的增量 R 明显高于 G(= 基色),电介质的增量三个通道基本相等(= 白光)。
 *     then looks at the R/G gap: the metal's increment has R well above G (= the base colour) while the
 *     dielectric's three channels rise almost equally (= white light).
 *   · **粗糙度**:不是"粗糙更暗",而是**同一个球的半高全宽**(FWHM)——
 *   · **roughness**: not "rough is darker" but the **full width at half maximum** on the same sphere:
 *     粗糙度调大 ⇒ 峰值**变低**、宽度**变大**(各按自己的半高量,见 `hl_probe`)。
 *     raising roughness **lowers the peak** and **widens** the lobe (each measured against its own
 *     half maximum, see `hl_probe`).
 *   · **能量守恒**:metallic 0 → 1 时**漫反射项必须下降**(`albedo = base × (1 − metallic)`)——
 *   · **energy conservation**: going metallic 0 -> 1 must **lower the diffuse term**:
 *     金属球背光侧那几个像素会被压成接近黑,而电介质球同位置仍然亮着。
 *     the metal sphere's off-highlight pixels collapse towards black while the dielectric's stay bright.
 *   · **光源数**:逐盏加上去,读数必须**单调上升**,而且**最后一盏**(第 8 盏)也要有可见效果 ——
 *   · **light count**: adding lights one by one must raise the reading **monotonically**, and the
 *     **last** one (light #8) must still visibly count -
 *     这一条直接验"着色器数组长度 == 宿主常量"那个坑(只改常量 ⇒ 多出来的灯**静默不生效**)。
 *     this one directly tests the "shader array length == host constant" trap (changing only the
 *     constant leaves the extra lights **silently doing nothing**).
 *
 * ## 跑法
 * ## How to run
 *   dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll \
 *          third_party/vml/Examples/c/3d/pbr_demo.c --screen 393x757
 *   手机:命令行页敲 `vml run examples/c/3d/pbr_demo.c`
 *   Phone: on the command-line page type `vml run examples/c/3d/pbr_demo.c`
 *
 * 机器判读:逐行 `PASS …` / `FAIL …`,末行 `PBR_DEMO PASS|FAIL (fails=N)`。
 * Machine-readable: one `PASS ...` / `FAIL ...` line each, ending with `PBR_DEMO PASS|FAIL (fails=N)`.
 */

#include <vml_ui.h>
#include <vml_3d.h>
#include <math.h>     /* ⚠ 见 g3_all.c 的文件头:漏了它 sin/cos 静默返回垃圾("原样返回实参"),
                       * See the g3_all.c header: without it sin/cos silently return garbage (the
                       * argument unchanged).
                       * 球体顶点全退化 ⇒ 一个像素都不画,而程序不报错、退出码 0。
                       * Every sphere vertex degenerates, nothing is drawn, and the program neither
                       * errors nor fails. */
#include <stdio.h>

/* ══ 记分 ══════════════════════════════════════════════════════════════════ */
/* == Scoring == */

int g_fails;

void ck(char* tag, int cond, int a, int b) {
    if (cond) { printf("PASS %s %d %d\n", tag, a, b); }
    else { g_fails = g_fails + 1; printf("FAIL %s %d %d\n", tag, a, b); }
}

int rch(int c) { return (c >> 16) & 0xFF; }
int gch(int c) { return (c >> 8) & 0xFF; }
int bch(int c) { return c & 0xFF; }
int lum(int c) { return (rch(c) * 30 + gch(c) * 59 + bch(c) * 11) / 100; }

/* ══ 网格装配(pos3 + nrm3 + uv2 = 8 个 float32 = 32 字节/顶点,紧随其后是 int32 索引)════
 * == Mesh assembly (pos3 + nrm3 + uv2 = 8 float32 = 32 bytes per vertex, then int32 indices) == */

/* ⚠ 缓冲大小**要按球算**:`gen_sphere(16,24,…)` = 17×25 = 425 个顶点 × 8 float = 3400,
 * Size the buffer from the sphere: 16x24 rings/segs = 425 vertices * 8 floats = 3400,
 *   再加上 16×24×6 = 2304 个 int 索引 —— 合起来 5704 个 float。
 * plus 16*24*6 = 2304 index ints = 5704 floats in total.
 * 第一版照抄 g3_all 的 4400(它那个球只有 8×10)⇒ **越界写**,而 C 前端不查边界。
 * The first version copied g3_all's 4400 (its sphere is only 8x10) and wrote past the end;
 * C does not bounds-check, so the mesh "built fine" while nothing appeared on screen. */
float MB[6144];
int g_vn;
int g_in;

void mb_vertex(int i, float px, float py, float pz,
               float nx, float ny, float nz, float u, float v) {
    int b = i * 8;
    MB[b + 0] = px; MB[b + 1] = py; MB[b + 2] = pz;
    MB[b + 3] = nx; MB[b + 4] = ny; MB[b + 5] = nz;
    MB[b + 6] = u;  MB[b + 7] = v;
}
void mb_index(int k, int v) { int* ip; ip = (int*)&MB[0]; ip[g_vn * 8 + k] = v; }
int mb_ptr(void) { return (int)&MB[0]; }

/* 经纬球(法线 = 归一化位置)。**判据必须用球**:平板上的法线处处相同 ⇒ 高光是均匀的一片,
 * A lat/long sphere (normal = normalized position). **The criteria need a sphere**: on a flat quad
 * the normal is identical everywhere, so the highlight is a uniform patch.
 * 那样既量不出"宽度随粗糙度变化",也量不出"中心 vs 稍远处"的增量。
 * Then you can measure neither "the width changes with roughness" nor the "centre versus a point a
 * little further out" increment. */
void gen_sphere(int rings, int segs, float r) {
    int i; int j; int k; int v;
    v = 0;
    for (i = 0; i <= rings; i = i + 1) {
        float phi = 3.14159265f * i / rings;
        float sp = sin(phi);
        float cp = cos(phi);
        for (j = 0; j <= segs; j = j + 1) {
            float th = 6.28318531f * j / segs;
            float nx = sp * cos(th);
            float ny = cp;
            float nz = sp * sin(th);
            mb_vertex(v, nx * r, ny * r, nz * r, nx, ny, nz, 0.0f, 0.0f);
            v = v + 1;
        }
    }
    g_vn = v;
    k = 0;
    for (i = 0; i < rings; i = i + 1) {
        for (j = 0; j < segs; j = j + 1) {
            int a = i * (segs + 1) + j;
            int b = a + segs + 1;
            mb_index(k, a);     k = k + 1;
            mb_index(k, b);     k = k + 1;
            mb_index(k, a + 1); k = k + 1;
            mb_index(k, a + 1); k = k + 1;
            mb_index(k, b);     k = k + 1;
            mb_index(k, b + 1); k = k + 1;
        }
    }
    g_in = k;
}


/* 环境反射的**离散度**与**最大相邻跳变**:在球心那一行横扫(跳过中间 ±12 px 的镜面高光)。
 * Spread and maximum adjacent step of the environment reflection along the centre row (skipping the
 * +-12 px specular core in the middle).
 * ⚠ 离散度用 R 通道(天空的 R 是三角波、B 是它的反相)—— 两通道都行,挑一个就好,别两个都量。
 * ⚠ The spread uses the R channel (the sky's R is the triangle wave, B is its inverse) - either works;
 *   measuring both adds nothing. */
int g_env_step;
int env_spread(int vp, int cx, int cy, int rad) {
    int x; int v; int lo; int hi; int prev; int step; int c;
    lo = 999; hi = -1; prev = -1; step = 0;
    for (x = -rad * 7 / 10; x <= rad * 7 / 10; x = x + 1) {
        if (x > -12 && x < 12) continue;
        c = ui_3d_viewport_get_pixel(vp, cx + x, cy);
        v = rch(c);
        if (v < lo) lo = v;
        if (v > hi) hi = v;
        if (prev >= 0 && (v - prev > step)) step = v - prev;
        if (prev >= 0 && (prev - v > step)) step = prev - v;
        prev = v;
    }
    g_env_step = step;
    return hi - lo;
}

int sx(int sc) { return sc & 0xFFFF; }
int sy(int sc) { return (sc >> 16) & 0xFFFF; }

/* ══ 取样 ══════════════════════════════════════════════════════════════════ */
/* == Sampling == */

/* 背景是**纯蓝**(B=255)而物体色里 B 最高只到 100 上下 ⇒ "这一格是不是物体"只看 B 通道。
 * The background is **pure blue** (B=255) while the object's B never goes far above 100, so "is this
 * pixel on the object" is decided by the B channel alone. */
int on_obj(int c) { return bch(c) < 128; }

/* 从球心往右扫,量出**这个球在屏幕上的半径**(像素)—— 判据里所有取样点都按它的比例摆,
 * Scan right from the sphere centre to measure the sphere's **on-screen radius** in pixels; every
 * probe point below is placed as a fraction of it.
 * 不按屏幕尺寸猜(换分辨率 / 换屏幕比例都还成立)。
 * It is never guessed from the screen size, so the criteria hold on any resolution. */
int radius_px(int vp, int cx, int cy) {
    int i;
    for (i = 1; i < 400; i = i + 1) {
        if (!on_obj(ui_3d_viewport_get_pixel(vp, cx + i, cy))) return i - 1;
    }
    return 0;
}

/* 高光探测:在球心那一行上横扫 ±0.8r,给出**峰值**与**半高全宽**(相对各自的峰值算)。
 * Highlight probe: sweep +-0.8r along the centre row and report the **peak** and the **full width at
 * half maximum** (each relative to its own peak).
 * ⚠ 半高必须**按自己的峰值**算:拿一个绝对阈值去比两条不同峰值的曲线,量到的是"谁更亮"。
 * ⚠ The half maximum must be taken **against each curve's own peak**: comparing two curves with an
 *   absolute threshold measures "which one is brighter".
 *   而那正是这条判据要分辨的东西的反面 —— 它要的是"谁更宽"。
 *   What this criterion is about is the opposite: which one is **wider**. */
void hl_probe(int vp, int cx, int cy, int rad, int* peakOut, int* widthOut) {
    int x; int v; int peak; int lo; int thr; int w;
    lo = 999;
    peak = -1;
    for (x = -rad * 8 / 10; x <= rad * 8 / 10; x = x + 1) {
        v = lum(ui_3d_viewport_get_pixel(vp, cx + x, cy));
        if (v > peak) peak = v;
        if (v < lo) lo = v;
    }
    thr = lo + (peak - lo) / 2;
    w = 0;
    for (x = -rad * 8 / 10; x <= rad * 8 / 10; x = x + 1) {
        if (lum(ui_3d_viewport_get_pixel(vp, cx + x, cy)) >= thr) w = w + 1;
    }
    *peakOut = peak;
    *widthOut = w;
}

int main(void) {
    int w; int h; int vp; int mesh;
    int o_die; int o_met;
    int r; int i;
    int cxd; int cyd; int rxd; int cxm; int cym; int rxm;
    int hi_d; int lo_d; int hi_m; int lo_m;
    int dRd; int dGd; int dRm; int dGm;
    int p_sharp; int w_sharp; int p_rough; int w_rough;
    int base_d; int base_m; int base_x; int base_y;
    int g0; int g1; int g2;
    int ed_d; int ed_m;
    int img_sky; int env_none; int env_pbr_off; int env_px;
    int sp_mirror; int sp_rough; int env_fixed;
    int with1; int with8; int prev; int cur; int stepped; int laststep;
    int sc;

    g_fails = 0;

    ui_win_open_ex("PBR 示例", 0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
    w = ui_scr_get_w_ex(ui_scr_get_orientation(), VML_CHROME_NONE);
    h = ui_scr_get_h_ex(ui_scr_get_orientation(), VML_CHROME_NONE);
    printf("SCR=%dx%d MAXLIGHTS=%d\n", w, h, VML3D_MAX_LIGHTS);
    if (w < 64 || h < 64) { printf("PBR_DEMO FAIL (fails=99)\n"); return 99; }

    ui_2d_clear(0);
    ui_2d_present();

    /* ── ① 视口 + 相机 + 一个光源(正对相机 ⇒ 高光落在球的正面极点)────────────────
     * -- 1) Viewport + camera + one light head-on to the camera, so the highlight sits on the
     *       sphere's front pole --
     * 相机在 (0,0,3)、竖直张角 60° ⇒ 距离 3 处**满屏高 = 2·3·tan30° = 3.464 世界单位**。
     * Camera at (0,0,3) with a 60-degree **vertical** angle: at distance 3 the **full screen height** is
     * 2*3*tan(30) = 3.464 world units. */
    vp = ui_3d_viewport_create(0, 0, w, h, 0);
    ck("viewport -> handle", vp > 0, vp, 0);
    ui_3d_viewport_set_camera(vp, 0.0f, 0.0f, 3.0f, 0.0f, 0.0f, 0.0f);
    ui_3d_viewport_set_fov(vp, 60.0f, 0.1f, 100.0f);
    ui_3d_viewport_set_bg(vp, 0xFF0000FF);            /* 纯蓝背景(物体里 B 到不了 128) */
                                                     /* Pure blue background (the object never reaches B=128). */
    ui_3d_viewport_set_light(vp, 0.0f, 0.0f, 1.0f, 0xFFFFFFFF, 0.05f);   /* 正对相机、环境光很低 */
                                                                        /* head-on, very low ambient */

    gen_sphere(16, 24, 1.0f);
    mesh = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    ck("sphere mesh -> handle", mesh > 0, mesh, 0);

    /* 两个球:**同一个底色、同一个粗糙度**,只差金属度 —— 判据的对照就靠它。
     * Two spheres: **same base colour, same roughness**, differing only in metallic - that is the control. */
    /* ⚠ 竖着摆(±0.52)而不是横着:手机屏是**窄的**(393×757),横向摆两个 0.28 半径的球会出屏。
     * ⚠ Stacked **vertically** (±0.52), not horizontally: the phone screen is **narrow** (393x757) and
     *   two 0.28-radius spheres side by side would fall off it. */
    o_die = ui_3d_obj_create(vp, mesh);
    ui_3d_obj_set_color(o_die, 0xFFC87A3C);           /* R=200 G=122 B=60:三通道**互不相等** */
                                                     /* R=200 G=122 B=60: the three channels are **distinct**. */
    ui_3d_obj_set_scale(o_die, 0.28f);
    ui_3d_obj_set_pos(o_die, 0.0f, 0.52f, 0.0f);

    o_met = ui_3d_obj_create(vp, mesh);
    ui_3d_obj_set_color(o_met, 0xFFC87A3C);
    ui_3d_obj_set_scale(o_met, 0.28f);
    ui_3d_obj_set_pos(o_met, 0.0f, -0.52f, 0.0f);

    /* ⚠ **固定管线的高光(`#638`)一个字都不设** —— 默认 specular=0。
     * ⚠ The **fixed-pipeline highlight (#638) is left at its default (specular = 0)**.
     *   这样"看到的高光"只可能来自 PBR,判据不会把两套光照的贡献搞混。
     *   So any highlight we see can only come from PBR - the criteria can never mix the two paths up. */

    sc = ui_3d_obj_get_screen(o_die);
    cxd = sx(sc); cyd = sy(sc);
    sc = ui_3d_obj_get_screen(o_met);
    cxm = sx(sc); cym = sy(sc);
    printf("CENTRES die=%d,%d met=%d,%d\n", cxd, cyd, cxm, cym);

    /* ⚠ **取证**:两个球真的在屏幕上(否则后面所有判据都是"隔着背景比背景")。
     * ⚠ **Evidence first**: both spheres really are on screen (otherwise every later criterion compares
     *   background with background). */
    ui_2d_present();
    rxd = radius_px(vp, cxd, cyd);
    rxm = radius_px(vp, cxm, cym);
    printf("RADIUS die=%d met=%d\n", rxd, rxm);
    ck("both spheres are really on screen (radius > 40 px)", rxd > 40 && rxm > 40, rxd, rxm);

    /* ── ② 基线:PBR 关着(默认)───────────────────────────────────────────
     * -- 2) Baseline with PBR off (the default) -- */
    base_d = ui_3d_viewport_get_pixel(vp, cxd, cyd);
    base_m = ui_3d_viewport_get_pixel(vp, cxm, cym);
    base_x = ui_3d_viewport_get_pixel(vp, cxd + rxd * 7 / 10, cyd);
    base_y = ui_3d_viewport_get_pixel(vp, cxm + rxm * 7 / 10, cym);
    printf("PBR_OFF die=%d,%d,%d met=%d,%d,%d\n",
           rch(base_d), gch(base_d), bch(base_d), rch(base_m), gch(base_m), bch(base_m));
    /* 固定管线下两个球**逐位相同**(同底色、无高光、同样的光照与几何)—— 这条本身是"没开 PBR"的取证。 */
    /* Under the fixed pipeline the two spheres are **bit-identical** (same base, no highlight, same
     * light and geometry) - which is itself the evidence that PBR is not on. */
    ck("PBR off: the two spheres are bit-identical (nothing metallic is happening)", base_d == base_m, base_d, base_m);

    /* ── ③ 打开 PBR:画面**必须变**(否则"开了没效果")────────────────────────
     * -- 3) Turn PBR on: the picture **must change** -- */
    r = ui_3d_viewport_set_pbr(vp, 1);
    ck("set_pbr(1) rc", r == 0, r, 0);
    ck("obj_set_pbr rc", ui_3d_obj_set_pbr(o_die, 0.0f, 0.55f) == 0
                      && ui_3d_obj_set_pbr(o_met, 1.0f, 0.55f) == 0, 0, 0);
    ui_2d_present();

    hi_d = ui_3d_viewport_get_pixel(vp, cxd, cyd);
    hi_m = ui_3d_viewport_get_pixel(vp, cxm, cym);
    printf("PBR_ON die=%d,%d,%d met=%d,%d,%d\n",
           rch(hi_d), gch(hi_d), bch(hi_d), rch(hi_m), gch(hi_m), bch(hi_m));
    ck("PBR changes the picture (not a no-op)", hi_d != base_d || hi_m != base_m, hi_d, base_d);

    /* ── ④ 金属 vs 电介质:**高光带不带底色** ──────────────────────────────
     * -- 4) Metal vs dielectric: does the **highlight carry the base tint** -- */
    /* 取样点:高光中心(球心那一点)与**同一个球上稍远处**(0.25r,仍在球面上)。
     * Probe points: the highlight centre (the sphere's own centre pixel) and a point **a little further
     * out on the same sphere** (0.25r, still on the sphere). */
    /* ⚠ 参考点**不能太远**:球面上法线在变,漫反射本身也有梯度(那同样是带底色的)。
     * ⚠ The reference point must **not be far**: the normal changes across a sphere so the diffuse has
     *   a gradient of its own (also tinted).
     *   拉到 0.25r 处漫反射只变 3% 上下,而高光已经掉到峰值的 40% ⇒ 两者量级分得开。
     *   At 0.25r the diffuse moved only about 3% while the highlight already dropped to some 40% of its
     *   peak - the two are well separated. */
    lo_d = ui_3d_viewport_get_pixel(vp, cxd + rxd / 4, cyd);
    lo_m = ui_3d_viewport_get_pixel(vp, cxm + rxm / 4, cym);
    dRd = rch(hi_d) - rch(lo_d);
    dGd = gch(hi_d) - gch(lo_d);
    dRm = rch(hi_m) - rch(lo_m);
    dGm = gch(hi_m) - gch(lo_m);
    printf("HILITE die_inc=%d,%d met_inc=%d,%d\n", dRd, dGd, dRm, dGm);
    /* ★ 金属的增量**带底色**(R 明显高于 G —— 基色是 R>G>B);
     * ★ the metal's increment **carries the base tint** (R well above G; the base is R>G>B); */
    ck("metal highlight is tinted by the base colour (dR - dG large)", dRm - dGm > 15, dRm, dGm);
    /* ★ 电介质的增量**基本无底色**(4% 的白光,三个通道一起抬)⇒ R 与 G 的差远小于金属的。 */
    /* ★ the dielectric's increment is **essentially neutral** (a 4% white lobe lifts all channels
     *   together), so its R-G gap is far smaller. */
    ck("dielectric highlight is neutral (dR - dG small, and clearly below the metal's)",
       (dRd - dGd) < (dRm - dGm) - 10 && (dRd - dGd) < 12, dRd, dGd);

    /* ── ⑤ 能量守恒:metallic 0 → 1,**漫反射项必须下降** ──────────────────
     * -- 5) Energy conservation: metallic 0 -> 1 must **lower the diffuse term** -- */
    /* 背光侧(0.7r)那一圈:电介质球上是"基色 × 光照",金属球上只剩高光的尾巴 ⇒ 明显更暗。
     * The band at 0.7r: on the dielectric it is "base x lighting", on the metal only the highlight's
     * tail - clearly darker. */
    ed_d = ui_3d_viewport_get_pixel(vp, cxd + rxd * 7 / 10, cyd);
    ed_m = ui_3d_viewport_get_pixel(vp, cxm + rxm * 7 / 10, cym);
    printf("ENERGY edge die=%d met=%d\n", lum(ed_d), lum(ed_m));
    ck("metal has (almost) no diffuse: its off-highlight pixel is much darker",
       lum(ed_m) < lum(ed_d) - 20, lum(ed_m), lum(ed_d));

    /* ── ⑥ 粗糙度:峰值**变低**、半高宽**变大** ─────────────────────────────
     * -- 6) Roughness: the peak **drops** and the FWHM **widens** -- */
    ui_3d_obj_set_pbr(o_met, 1.0f, 0.35f);
    ui_2d_present();
    hl_probe(vp, cxm, cym, rxm, &p_sharp, &w_sharp);
    ui_3d_obj_set_pbr(o_met, 1.0f, 0.85f);
    ui_2d_present();
    hl_probe(vp, cxm, cym, rxm, &p_rough, &w_rough);
    printf("ROUGH sharp peak=%d width=%d | rough peak=%d width=%d\n", p_sharp, w_sharp, p_rough, w_rough);
    /* ★ **变弱**:粗糙的那个峰值必须明显低(不是"整体变暗",见下一条)。 */
    /* ★ **dimmer**: the rough one's peak must be clearly lower (this is not "everything got darker", see
     *   the next line). */
    ck("roughness up -> the peak drops", p_rough < p_sharp - 20, p_rough, p_sharp);
    /* ★ **变大** —— 这一条才是"高光区变大",只测峰值那一维会把它漏掉。
     * ★ **wider** - this is the "the highlight area grew" half; measuring only the peak would miss it. */
    ck("roughness up -> the half-max width grows", w_rough > w_sharp, w_rough, w_sharp);
    ui_3d_obj_set_pbr(o_met, 1.0f, 0.55f);

    /* ── ⑦ 光源数:逐盏加上去,读数必须**单调上升**,第 8 盏也要有效果 ─────────
     * -- 7) Light count: adding lights one by one must be **monotonic**, and light #8 must count -- */
    /* ⚠ 这一条直接验"着色器数组长度 == 宿主常量"那个坑:只改常量不改着色器 ⇒ 第 5–8 盏
     * ⚠ This directly tests the "shader array length == host constant" trap: change only the constant.
     *   **静默不生效**(`GL_INVALID_OPERATION` 被吞掉),读数会在第 4 盏之后**卡住不动**。
     *   Lights 5-8 then **silently do nothing** (the GL error is swallowed) and the reading freezes
     *   after light #4. */
    ui_2d_present();
    with1 = lum(ui_3d_viewport_get_pixel(vp, cxd, cyd));
    prev = with1;
    stepped = 0;
    laststep = 0;
    for (i = 1; i < VML3D_MAX_LIGHTS; i = i + 1) {
        /* 第 i 盏:方向偏一点(正对极点时 ndl 只有 0.4)⇒ 每盏只加一点点,不会把读数顶到饱和 */
        /* Light #i: angled a little (ndl is only 0.4 at the front pole), so each adds a little and the
         * reading never saturates. */
        r = ui_3d_viewport_set_light_at(vp, i, 0.9f, 0.2f, 0.4f, 0x101010, 0.0f);
        if (r != 0) { ck("light_at rc", 0, r, i); }
        ui_2d_present();
        cur = lum(ui_3d_viewport_get_pixel(vp, cxd, cyd));
        printf("LIGHTS n=%d lum=%d\n", i + 1, cur);
        if (cur > prev) stepped = stepped + 1;
        laststep = cur - prev;
        prev = cur;
    }
    with8 = prev;
    ck("every extra light raises the reading (monotonic)", stepped == VML3D_MAX_LIGHTS - 1, stepped, VML3D_MAX_LIGHTS - 1);
    /* ★ **最后一盏也要有可见效果** —— 这是"多出来的灯静默不生效"那个坑的正面判据。 */
    /* ★ **the last one must visibly count** - the positive criterion for the "extra lights silently do
     *   nothing" trap. */
    ck("the LAST light still counts (>= 2 levels)", laststep >= 2, laststep, with8 - with1);

    /* ── ⑧ PBR 的线性输出**走同一个出口**(`#649` 的伽马/色调映射)──────────────
     * -- 8) The PBR linear output goes through the **same exit** (#649 gamma/tone mapping) -- */
    g0 = ui_3d_viewport_get_pixel(vp, cxd, cyd);
    ui_3d_viewport_set_gamma(vp, 1);
    ui_2d_present();
    g1 = ui_3d_viewport_get_pixel(vp, cxd, cyd);
    printf("PBR_GAMMA off=%d sRGB=%d\n", rch(g0), rch(g1));
    ck("PBR output goes through apply_output (gamma on changes it)", rch(g1) != rch(g0), rch(g1), rch(g0));
    ui_3d_viewport_set_gamma(vp, 0);
    ui_2d_present();
    g2 = ui_3d_viewport_get_pixel(vp, cxd, cyd);
    ck("gamma back to 0 -> bit-identical", g2 == g0, g2, g0);

    /* ── ⑨ 默认关:把 PBR 关回去 ⇒ 画面**逐位**回到最初 ─────────────────────
     * -- 9) Off by default: switching PBR back off returns the picture **bit for bit** -- */
    /* ⚠⚠ **比的是"关两次"而不是"与最上面那一帧"**:上面那些判据把光源从 1 盏加到了 8 盏。
     * ⚠⚠ Compare "off twice" rather than "against the very first frame": earlier criteria added lights
     *   (1 -> 8), so the scene state has moved on.
     *   拿最初那一帧当基准是**问错了对象**(那会把"光源数变了"读成"关不干净")。
     *   Using the first frame as the baseline asks the wrong question - it would read "the lights
     *   changed" as "turning it off is not clean".
     *   正确的判据形态:**同一场景、同一时刻**,只翻开关这一位 ⇒ 开与关不同、关与关逐位相同。
     *   The right shape is **same scene, same moment**, flipping only this one bit: on differs from
     *   off, and off equals off. */
    /* ⚠ 逐物体参数**不还原** —— 这一条要证的正是"开关关着时那些参数一个字节都进不了着色器"。
     * ⚠ The per-object parameters are **not** reset: this proves exactly that "with the switch off
     *   those numbers never reach the shader".
     *   两个球身上还挂着 metallic=0/1、roughness=0.55,画面照样必须与"上一次关着"逐位相同。
     *   Both spheres still carry metallic 0/1 and roughness 0.55, yet the picture must be
     *   bit-identical to the previous off frame. */
    ui_3d_viewport_set_pbr(vp, 0);
    ui_2d_present();
    base_x = ui_3d_viewport_get_pixel(vp, cxd, cyd);
    base_y = ui_3d_viewport_get_pixel(vp, cxm, cym);
    ui_3d_viewport_set_pbr(vp, 1);
    ui_2d_present();
    cur = ui_3d_viewport_get_pixel(vp, cxm, cym);
    ui_3d_viewport_set_pbr(vp, 0);
    ui_2d_present();
    printf("PBR_TOGGLE off=%d,%d,%d on=%d,%d,%d
",
           rch(base_x), gch(base_x), bch(base_x), rch(cur), gch(cur), bch(cur));
    ck("PBR on vs off differ (the switch really does something)", cur != base_y, cur, base_y);
    ck("PBR off again -> bit-identical (per-object params are ignored while off)",
       ui_3d_viewport_get_pixel(vp, cxd, cyd) == base_x
       && ui_3d_viewport_get_pixel(vp, cxm, cym) == base_y, 0, 0);

    /* ── ⑪ 环境反射(**近似**,`#652` 开着且设了天空盒才有)───────────────────
     * -- 11) Environment reflection (**an approximation**; needs PBR on and a skybox set) -- */
    /* ⚠ 这一段**必须放在最后**:天空盒会把整个背景换掉,而前面所有判据都靠"纯蓝背景"分辨
     * ⚠ This section goes **last**: the skybox replaces the whole background, and every earlier
     *   criterion relies on the pure-blue background to tell
     *   "这一格是不是物体"(见 on_obj)。球的半径 `rxm` 在前面已经量过 ⇒ 这里直接沿用。
     *   "is this pixel on the object" (see on_obj). The radius rxm was measured earlier, so it is
     *   reused here. */
    /* 等距圆柱"天空":u 方向一个**周期为 64 列的三角波**(R 与 B 反相、G 压得很低)。
     * An equirect "sky": a **triangle wave with a 64-column period** along u (R anti-phased with B,
     * G kept low).
     * 而且在 ±180°(u=0 / u=1)处**连续** —— 接缝判据就靠这个连续性。
     * And it is **continuous** at +-180 (u=0 / u=1) - the seam criterion depends on that. */
    for (i = 0; i < 128; i = i + 1) {
        int k = i % 64;
        int rr = (k < 32) ? (k * 8) : ((64 - k) * 8);
        ui_2d_rect(8 + i, 8, 1, 16, 0xFF000000 | (rr << 16) | (64 << 8) | (255 - rr), 1, 0, 0);
    }
    ui_2d_present();
    img_sky = ui_2d_get_image(8, 8, 128, 16);
    /* ⚠ 抓完图必须清 2D 层,否则它盖在 3D 视口上面(tex_demo 实测)。
     * ⚠ The 2D layer must be cleared right after grabbing, or it covers the 3D viewport (tex_demo). */
    ui_2d_clear(0);
    ui_2d_present();
    ck("sky (equirect) image -> handle", img_sky > 0, img_sky, 0);

    /* ⚠⚠ **三条读数必须在同一个状态下量**(粗糙度、光源、开关都不能变),差出来的才是"环境"。
     * ⚠⚠ The three readings must be taken **in the same state** (same roughness, lights and switch);
     *   only then is the difference "the environment".
     *   第一版把"没有环境"量在 roughness 0.55、把"有环境"量在 0.1 ⇒ 差里混着"高光形状变了"。
     *   The first version measured "no env" at roughness 0.55 and "with env" at 0.1, so the difference
     *   mixed in "the highlight changed shape".
     *   读数(41 vs 28)看着像"反射让它更暗",其实只是**问错了对象**。
     *   The readings (41 vs 28) looked like "the reflection made it darker" when the question had
     *   simply been asked of the wrong thing. */
    ui_3d_viewport_set_pbr(vp, 1);
    /* 镜面:反射最清楚,高光也最窄。
     * A mirror: the reflection is clearest and the highlight is at its narrowest. */
    ui_3d_obj_set_pbr(o_met, 1.0f, 0.1f);
    ui_2d_present();
    env_none = lum(ui_3d_viewport_get_pixel(vp, cxm + rxm * 7 / 10, cym));

    /* 对照组:**关掉 PBR**(= 老程序那条固定管线)时,设不设天空盒画面都不该变。
     * Control: with PBR **off** (the old fixed pipeline) setting a skybox must change nothing.
     * "天空盒只是背景、不参与光照"这条保证就靠它。
     * That is the "a skybox is only a background, it does not light anything" guarantee. */
    ui_3d_viewport_set_pbr(vp, 0);
    ui_2d_present();
    env_pbr_off = lum(ui_3d_viewport_get_pixel(vp, cxm + rxm * 7 / 10, cym));
    r = ui_3d_viewport_set_skybox(vp, img_sky);
    ck("set_skybox rc", r == 0, r, 0);
    ui_2d_present();
    env_fixed = lum(ui_3d_viewport_get_pixel(vp, cxm + rxm * 7 / 10, cym));
    printf("ENV fixed no_sky=%d with_sky=%d | mirror no_env=%d
", env_pbr_off, env_fixed, env_none);
    ck("skybox alone (PBR off) does NOT light the sphere - it is only a background",
       env_fixed == env_pbr_off, env_fixed, env_pbr_off);

    /* 同一状态:PBR 开、roughness 0.1。
     * Same state as above: PBR on, roughness 0.1. */
    ui_3d_viewport_set_pbr(vp, 1);
    ui_2d_present();
    env_px = ui_3d_viewport_get_pixel(vp, cxm + rxm * 7 / 10, cym);
    printf("ENV with skybox=%d,%d,%d (no env was %d)
", rch(env_px), gch(env_px), bch(env_px), env_none);
    ck("a metal sphere finally reflects the environment (was near black)",
       lum(env_px) > env_none + 10, lum(env_px), env_none);
    /* ★ 反射必须是**有色的**(跟着天空走),不是"整体提亮成灰的"。 */
    /* ★ the reflection must be **coloured** (following the sky), not "uniformly brightened grey". */
    ck("the reflection is tinted by the sky (not a grey lift)",
       gch(env_px) + 20 < rch(env_px) || gch(env_px) + 20 < bch(env_px), rch(env_px), gch(env_px));

    /* ── ⑫ 粗糙度把反射**糊开**(廉价预过滤:按粗糙度选 mip 级)────────────────
     * -- 12) Roughness **blurs** the reflection (cheap pre-filter: pick a mip level by roughness) -- */
    /* 在球心那一行横扫(跳过中间 ±12 px 的镜面高光),量 R 通道的**离散度**。
     * Sweep the centre row (skipping the +-12 px specular core) and measure the **spread** of R.
     * 镜面时看到整条彩色天空(离散度大),粗糙时 mip 把天空平均掉(离散度塌下去)。
     * A mirror shows the whole coloured sky (large spread); a rough surface averages it away. */
    sp_mirror = env_spread(vp, cxm, cym, rxm);
    ui_3d_obj_set_pbr(o_met, 1.0f, 0.95f);
    ui_2d_present();
    sp_rough = env_spread(vp, cxm, cym, rxm);
    printf("ENV_SPREAD mirror=%d rough=%d
", sp_mirror, sp_rough);
    ck("roughness blurs the reflection (the spread collapses)", sp_rough < sp_mirror, sp_rough, sp_mirror);

    /* ⚠ **接缝**:等距圆柱在 ±180° 有缝,而 mip 选择会让它更明显。
     * ⚠ **Seam**: an equirect map has a seam at +-180 and mip selection can make it worse.
     *   这里量的是"相邻像素之间最大的跳变"(`env_spread` 里顺手取出来的)。
     *   What is measured is the **largest jump between adjacent pixels** (taken inside env_spread).
     *   真的糊出一道竖条时它是一个**尖峰**,而正常的天空渐变每个像素只走几级。
     *   A smeared vertical band shows up as a **spike** while a normal sky gradient moves a few levels
     *   per pixel. The number is printed as-is; the criterion only catches the spike. */
    printf("ENV_SEAM max_step=%d
", g_env_step);
    ck("no seam spike in the reflection (max adjacent step is a gradient, not a band)",
       g_env_step < 60, g_env_step, 60);

    /* ── ⑩ 参数校验:坏句柄 / NaN 一律**响亮地**拒 ──────────────────────────
     * -- 10) Argument checking: bad handles and NaN are **loudly** rejected -- */
    ck("set_pbr(bad vp) rejected", ui_3d_viewport_set_pbr(9999, 1) < 0, 0, 0);
    ck("obj_set_pbr(bad obj) rejected", ui_3d_obj_set_pbr(9999, 0.0f, 0.5f) < 0, 0, 0);
    ck("obj_set_pbr(NaN metallic) rejected (a NaN BRDF turns the pixel into a black hole)",
       ui_3d_obj_set_pbr(o_die, 0.0f / 0.0f, 0.5f) < 0, 0, 0);
    ck("obj_set_pbr(NaN roughness) rejected", ui_3d_obj_set_pbr(o_die, 0.5f, 0.0f / 0.0f) < 0, 0, 0);
    /* 有限值**钳位**(连续量钳、枚举量报错 —— 与 `#638` 的 shininess 同一条口径) */
    /* Finite values are **clamped** (continuous clamps, enumerations error - same rule as #638). */
    ck("obj_set_pbr(-1, 2) is accepted (finite -> clamped, not an error)",
       ui_3d_obj_set_pbr(o_die, -1.0f, 2.0f) == 0, 0, 0);

    if (g_fails == 0) printf("PBR_DEMO PASS (fails=0)\n");
    else printf("PBR_DEMO FAIL (fails=%d)\n", g_fails);
    return g_fails;
}