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

c/3d/gltf_demo.c · 22747 字节 · 下载原文

/* gltf_demo.c —— **从外部 glTF 2.0 / GLB 文件装载一整个场景**(网格 + 材质 + 贴图 + 节点树)。
 * gltf_demo.c - **load a whole scene from an external glTF 2.0 / GLB file** (mesh + material + texture + node tree).
 * 用户 2026-10-07:「这样做的目的是**完善我们的 3D 支持库**」。
 * User, 2026-10-07: "the point is to **complete our 3D support library**".
 * 与 `objload.c` 的差别:OBJ 只有顶点;glTF 一次给全(`ui_3d_mesh_load` 只认 `.obj`)。
 * Difference from `objload.c`: OBJ is only vertices, glTF gives everything at once (`ui_3d_mesh_load` is `.obj` only).
 *
 * ## 夹具(**不进仓库**)
 * ## The fixture (**not committed to the repo**)
 * 下面按顺序试几个路径,第一个装上的就是它 —— 手机以工作区为根,桌面以**本文件所在目录**为根。
 * The paths below are tried in order and the first one that loads wins - the phone roots them at the workspace, the desktop at the **directory of this file**.
 * 推荐把 Khronos 的 **CC0** 样例 `BoomBox.glb`(9.5 MB)放到 `Examples/c/3d/models/`。
 * Put the Khronos **CC0** sample `BoomBox.glb` (9.5 MB) into `Examples/c/3d/models/`.
 * 没放就打一行 `GLTF_SKIP` —— **不是 PASS**(没跑过的判据不许当通过)。
 * Without it one `GLTF_SKIP` line is printed - **not** PASS (an assertion that never ran must not count as a pass).
 *
 * ## 判据(逐行 PASS / FAIL,末行给总数)
 * ## The assertions (one PASS / FAIL line each, plus a summary line)
 * 每一条都**能响**:把实现改坏(不建节点树 / 不接父子变换 / 不上传贴图)它必须立刻变红。
 * Every one of them **can fire**: break the implementation (skip the node tree, drop the parent link, skip the texture upload) and it goes red at once.
 *
 * ## 跑法
 * ## How to run it
 *   桌面:`dotnet <vmlcli.dll> Examples/c/3d/gltf_demo.c --screen 393x757 --timeout 120`
 *   Desktop: `dotnet <vmlcli.dll> Examples/c/3d/gltf_demo.c --screen 393x757 --timeout 120`
 *   出图:**要用 `--frames <目录>`,别用 `--frame`**(实测 2026-10-08:两者 md5 不同)。
 *   Export: **use `--frames <dir>`, not `--frame`** (measured 2026-10-08: the two differ by md5).
 *   `--frames` 按"呈现那一刻"落盘(带贴图),`--frame` 取的是**运行结束时**的 FBO,
 *   `--frames` writes each frame at present time (textured); `--frame` reads the FBO **at the end of the run**,
 *   而那时贴图判据已经把贴图摘掉了(它**装不回来**)⇒ 拿到一张没贴图的图。
 *   by which time the texture assertion has detached the texture (which nothing ever restores).
 *   ⚠ 诊断(贴图降了多少、哪个特性被跳过)在 **stderr** 上,不在 stdout。
 *   ⚠ The diagnostics (how far a texture was downsampled, which feature was skipped) go to **stderr**.
 *   手机:命令行页敲 `vml run examples/c/3d/gltf_demo.c`
 *   Phone: on the command-line page type `vml run examples/c/3d/gltf_demo.c`
 */
#include <vml_ui.h>
#include <vml_3d.h>
#include <stdio.h>

/* 候选模型:**先手机的工作区路径,再桌面的"本文件旁边"**。
 * Candidate models: **the phone's workspace path first, then "next to this file" on the desktop**.
 * ⚠ 顺序不是随意的:两端 `ResolvePath` 的沙箱根本来就不同,这里如实各试一次(同 `objload.c`)。
 * ⚠ The order is not arbitrary: the two ends have genuinely different sandbox roots, so both are tried honestly (same as `objload.c`).
 */
static const char* kModels[] = {
    /* ⚠ 2026-10-10:换成**随包的小模型**(CC0)—— 原先的 BoomBox/Avocado 各 8–10 MB,
     *   而用户口径是「例子不需要大文件,打包太大」。逐份许可见 `models/CREDITS.md`。 */
    "examples/c/3d/models/AnimatedColorsCube.glb",
    "models/AnimatedColorsCube.glb",
    "AnimatedColorsCube.glb",
    "examples/c/3d/models/TextureLinearInterpolationTest.glb",
    "models/TextureLinearInterpolationTest.glb",
    "TextureLinearInterpolationTest.glb",
    /* ⚠ 2026-10-09:`Fox.glb` **已从仓库删除** —— 它文件内自带的 `asset.copyright` 写的是
     *   **CC-BY 4.0**(**要求署名**),而当时仓里把它记成了 CC0 ⇒ 一直无署名地随包分发。
     *   ⇒ 全新 clone 上本目录**没有任何模型**,这里会走到"没找到"那一支(打 SKIP/提示,**不是 PASS**)。
     *   哪些资产能随包发、要不要署名、以及怎么恢复覆盖,见同目录 `CREDITS.md`。
     * ⚠ 2026-10-09: `Fox.glb` was **removed from the repo** — its own `asset.copyright` says CC-BY 4.0
     *   (attribution required) while it was recorded here as CC0, so it shipped unattributed.
     *   A fresh clone now has **no model here**; this reaches the "not found" branch (SKIP, **not** PASS).
     *   See `CREDITS.md` for what may ship, what needs attribution, and how to restore coverage. */
    "examples/c/3d/models/AnimatedMorphCube.glb",
    "models/AnimatedMorphCube.glb",
    "AnimatedMorphCube.glb",
};
#define MODEL_COUNT ((int)(sizeof(kModels) / sizeof(kModels[0])))

/* 采样网格的边长:判据用它数"画了多少"与"贴图变没变"。
 * The side of the sampling grid: the assertions use it to count "how much was drawn" and "did the texture change anything".
 * ⚠ 41x41 = 1681 个点**不慢**:宿主只在"真重画过"那一拍才让整帧缓存失效(见 `ui_3d_viewport_get_pixel` 的说明)。
 * ⚠ 41x41 = 1681 samples are **not** slow: the host only invalidates its whole-frame cache on a frame it really redrew.
 */
#define GRID 41

static int g_fails = 0;

static void ck(const char* what, int ok, int got, int want) {
    if (ok) printf("PASS %s (%d)\n", what, got);
    else { printf("FAIL %s (got=%d want=%d)\n", what, got, want); g_fails = g_fails + 1; }
}

/* 采样网格上第 k 个点的颜色(k 从 0 数到 GRID*GRID-1,按行铺开)。
 * The colour of sample #k on the grid (k runs 0..GRID*GRID-1, laid out row by row).
 * ⚠ 抽成一个函数是**为了两次采样走同一串坐标** —— 两次若各算一遍,一旦有一边改了就再也对不上。
 * ⚠ It is a function so both passes walk **the same coordinates**; two separate loops would drift apart.
 */
static int grid_pixel(int vp, int sw, int sh, int k) {
    int px; int py;
    px = (sw - 1) * (k % GRID) / (GRID - 1);
    py = (sh - 1) * (k / GRID) / (GRID - 1);
    return ui_3d_viewport_get_pixel(vp, px, py);
}

/* 上一遍采样(摘贴图之前)的颜色 —— 静态数组,不占栈。 */
/* The colours from the previous pass (before the texture was detached) - static, not on the stack. */
static int g_before[GRID * GRID];

/* 两个颜色是不是**看着**不同:逐通道差超过 8 才算 —— 免得把光照上的浮点噪声当信号。
 * Whether two colours **look** different: a per-channel gap over 8 counts, so shading noise is not a signal.
 */
static int differ(int a, int b) {
    int d;
    d = ((a >> 16) & 255) - ((b >> 16) & 255); if (d < 0) d = -d; if (d > 8) return 1;
    d = ((a >> 8) & 255) - ((b >> 8) & 255); if (d < 0) d = -d; if (d > 8) return 1;
    d = (a & 255) - (b & 255); if (d < 0) d = -d; if (d > 8) return 1;
    return 0;
}

int main(void) {
    int vp; int sw; int sh;
    int sc; int i; int n; int root; int bg;
    int painted; int total; int painted_away;
    int moved; int changed;
    float bd[4];
    const char* used;

    ui_win_open_ex("GLTF glTF 2.0 GLTF_DEMO", 0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
    sw = ui_scr_get_w_ex(ui_scr_get_orientation(), VML_CHROME_NONE);
    sh = ui_scr_get_h_ex(ui_scr_get_orientation(), VML_CHROME_NONE);
    if (sw <= 0) sw = 640;
    if (sh <= 0) sh = 640;

    vp = ui_3d_viewport_create(0, 0, sw, sh, 0);
    if (vp <= 0) {
        /* ⚠ **明说**,不画一片黑让人猜(与 `objload.c` / `bench3d` 同一个口径)。 */
        /* ⚠ **Say it out loud** instead of painting a black screen and letting people guess. */
        printf("NO_3D_DEVICE=1 vp=%d\n", vp);
        return 0;
    }

    bg = -16250870;
    /* 深空色 0xFF08120A 的有符号写法(判据全程拿它当"底色")。 */
    /* Deep-space colour 0xFF08120A in signed form; every assertion uses it as "the background". */
    ui_3d_viewport_set_bg(vp, bg);
    ui_3d_viewport_set_light(vp, 0.45f, 1.0f, 0.35f, -1, 0.45f);
    ui_3d_viewport_set_depth(vp, 1);
    /* 有深度才看得出背后那一半被挡住 —— 与 `objload.c` 的取景口径一致。 */
    /* Depth is what makes the occluded half really occluded - same framing stance as `objload.c`. */

    /* ── ① 装载:按顺序试候选路径 ────────────────────────────────────────── */
    /* ── (1) Load: try the candidate paths in order ─────────────────────── */
    sc = 0;
    used = "";
    for (i = 0; i < MODEL_COUNT; i++) {
        sc = ui_3d_gltf_load(vp, kModels[i]);
        if (sc > 0) { used = kModels[i]; break; }
    }
    printf("GLTF_FILE=%s\n", used);
    if (sc <= 0) {
        /* ⚠ 这是 **SKIP 不是 PASS**:夹具不在磁盘上时,下面那些判据一条都没有跑过。 */
        /* ⚠ This is a **SKIP, not a PASS**: with no fixture on disk none of the assertions below ever ran. */
        /* 本仓口径"不响的判据比没有更糟" —— 宁可打一行 SKIP,也不给一个假的绿。 */
        /* This repo's stance: "an assertion that cannot fire is worse than none" - a SKIP beats a fake green. */
        printf("GLTF_SKIP=1 reason=fixture-not-on-disk tried=%d\n", MODEL_COUNT);
        printf("GLTF_DEMO SKIP (put BoomBox.glb into Examples/c/3d/models/ or next to this file)\n");
        return 0;
    }

    /* ── ② 场景:句柄 / 物体数 / 根 / 包围球 ────────────────────────────── */
    /* ── (2) Scene: handle / object count / root / bounding sphere ──────── */
    n = ui_3d_gltf_object_count(sc);
    root = ui_3d_gltf_root(sc);
    bd[0] = 0.0f; bd[1] = 0.0f; bd[2] = 0.0f; bd[3] = 0.0f;
    i = ui_3d_gltf_bounds(sc, (int)&bd[0]);
    printf("GLTF_SCENE=%d objects=%d root=%d bounds=(%.3f,%.3f,%.3f) r=%.3f rc=%d\n",
           sc, n, root, bd[0], bd[1], bd[2], bd[3], i);

    ck("load returned a positive scene handle", sc > 0, sc, 1);
    ck("object count >= 3 (root + node + mesh)", n >= 3, n, 3);
    ck("root is a real object and equals object_at(0)", root > 0 && root == ui_3d_gltf_object_at(sc, 0), root, 0);
    ck("object_at out of range is rejected", ui_3d_gltf_object_at(sc, n) < 0, ui_3d_gltf_object_at(sc, n), -1);
    ck("bounds wrote 0 and a positive radius", i == 0 && bd[3] > 0.0f, i, 0);

    /* 相机按**模型自己的大小**摆 —— 这就是 `ui_3d_gltf_bounds` 存在的理由。 */
    /* The camera is placed from the **model's own size** - which is exactly why `ui_3d_gltf_bounds` exists. */
    /* ⚠⚠ **裁剪面也必须跟着模型走**(2026-10-08 实测踩到):默认 `znear = 1.0`,而 BoomBox 的半径只有 0.017。
     * ⚠⚠ **The clip planes must scale with the model too** - the default `znear = 1.0` against a BoomBox radius of 0.017.
     * 于是 `ui_3d_viewport_fit` 把相机放到距离 ≈ 0.04 ⇒ **整个模型落在近裁剪面以内** ⇒ 一个像素都画不出来。
     * So `ui_3d_viewport_fit` parks the camera about 0.04 away and the whole model sits inside the near plane.
     * 症状与"根本没装载"一模一样,而装载/节点树/读数的判据**全是绿的**(当时 `GLTF_PAINTED=0/1681`)。
     * The symptom looks exactly like "nothing was loaded" while loading, the node tree and every reading stay green.
     * **装载的是数据,取景的是调用方** —— 所以裁剪面按 `ui_3d_gltf_bounds` 给的半径推。
     * **Loading is data, framing is the caller's job** - so the clip planes are derived from the radius `ui_3d_gltf_bounds` returns. */
    ui_3d_viewport_set_fov(vp, 55.0f, bd[3] * 0.05f, bd[3] * 20.0f);
    ui_3d_viewport_set_camera(vp, bd[0], bd[1] + bd[3], bd[2] + 2.5f * bd[3], bd[0], bd[1], bd[2]);
    ui_3d_viewport_fit(vp, bd[3] * 1.05f);

    /* ── ③ 真的画出了东西吗 ────────────────────────────────────────────── */
    /* ── (3) Did anything actually get drawn? ───────────────────────────── */
    ui_2d_clear(0);
    ui_2d_present();
    printf("GLTF_CENTER=%d\n", ui_3d_viewport_get_pixel(vp, sw / 2, sh / 2));
    ck("centre pixel is not the clear colour", ui_3d_viewport_get_pixel(vp, sw / 2, sh / 2) != bg,
       ui_3d_viewport_get_pixel(vp, sw / 2, sh / 2), bg);

    painted = 0; total = 0;
    for (i = 0; i < GRID * GRID; i++) {
        total = total + 1;
        if (grid_pixel(vp, sw, sh, i) != bg) painted = painted + 1;
    }
    /* ⚠⚠ **不用"覆盖多少才算画了"这种阈值**(2026-10-08 踩到):第一版写的是"≥5% 的采样点是几何"。
     * ⚠⚠ **No "coverage threshold"** (measured 2026-10-08): the first version asked for "at least 5% of the samples are geometry".
     * 实测 BoomBox 11%、Avocado 12%,而 **Fox** 只有 **1.9%**(瘦长低模,包围球被尾巴和腿撑大)。
     * Measured: BoomBox 11%, Avocado 12%, but **Fox only 1.9%** (a skinny low-poly whose bounding sphere is inflated by tail and legs).
     * ⇒ 那是**一条只在某些模型上成立的判据**,换个模型就假红。
     * => that is a criterion that only holds for some models, and it goes falsely red on others.
     * 改成**自比**的 A/B:把根物体挪到画面外,几何像素必须归零 —— 与模型多大、多瘦无关。
     * It is now a **self-comparing** A/B: move the root off-screen and the geometry samples must drop to zero - independent of model size or shape.
     * ⚠⚠ "Nothing was drawn at all" can never pass this one. */
    ui_3d_obj_set_pos(root, bd[0] + 10000.0f, bd[1], bd[2]);
    painted_away = 0;
    for (i = 0; i < GRID * GRID; i++) if (grid_pixel(vp, sw, sh, i) != bg) painted_away = painted_away + 1;
    ui_3d_obj_set_pos(root, bd[0], bd[1], bd[2]);
    printf("GLTF_PAINTED=%d/%d AWAY=%d\n", painted, total, painted_away);
    ck("geometry samples > 0, and 0 once the root is moved off-screen",
       painted >= 1 && painted_away == 0, painted, painted_away);

    /* ── ④ 变换继承:**根一动,叶子必须跟着动** ────────────────────────── */
    /* ── (4) Transform inheritance: **move the root and the leaves must follow** ── */
    /* ⚠ 这一条才是"节点树真的展开了"的**强**判据:只建一个物体的实现过不了它 —— */
    /* ⚠ This is the **strong** criterion for "the node tree really got expanded": a single-object implementation cannot pass it - */
    /* 父子链没接上时,移动根不会让任何一个孩子改变屏幕坐标(`ui_3d_obj_get_screen`)。 */
    /* without the parent link, moving the root changes nobody's projected position from `ui_3d_obj_get_screen`. */
    ui_3d_obj_set_pos(root, bd[0], bd[1], bd[2]);
    moved = 0;
    for (i = 1; i < n && i < 32; i++) {
        int obj; int before; int after;
        obj = ui_3d_gltf_object_at(sc, i);
        before = ui_3d_obj_get_screen(obj);
        ui_3d_obj_set_pos(root, bd[0] + 0.35f * bd[3], bd[1], bd[2]);
        after = ui_3d_obj_get_screen(obj);
        ui_3d_obj_set_pos(root, bd[0], bd[1], bd[2]);
        if (before != after) moved = moved + 1;
    }
    printf("GLTF_MOVED=%d of %d\n", moved, n - 1);
    ck("moving the root moves its children (transform inheritance)", moved >= 1, moved, 1);

    /* ── ⑤ 先留下**带贴图**的那一帧,再做贴图判据 ──────────────────────── */
    /* ── (5) Leave the **textured** frame first, then run the texture assertions ── */
    /* ⚠⚠ **顺序不能反**(2026-10-08 实测踩到):下面那条判据会把贴图摘掉、再也装不回来。
     * ⚠⚠ **The order matters** (measured 2026-10-08): the assertion below detaches the texture and never restores it.
     * 于是放在它后面的 `ui_2d_present()` 交出去的是一张**没贴图**的图,而 `--frame` 取的正是最后那一帧。
     * So a `ui_2d_present()` after it exports an **untextured** frame, and `--frame` captures the last frame.
     * ⇒ 导出图与刚验过的效果**不是一回事**(这条是看图看出来的:两次导出的 PNG 逐字节相同)。
     * => the exported picture is not the one just verified (caught by looking: both exports were byte-identical).
     * ⚠⚠ **这一句不能省**:它把 2D 层清成**透明**,3D 视口才透得出来(少了它屏幕一片黑)。 */
    /*   ⚠⚠ **This line cannot be omitted**: it clears the 2D layer to **transparent** so the 3D viewport shows through. */
    /*   ⚠⚠ **This line cannot be omitted**: it clears the 2D layer to **transparent** so the 3D viewport shows through. */
    ui_2d_clear(0);
    ui_2d_present();

    /* ── ⑥ 贴图确实生效:**摘掉它,画面必须变** ────────────────────────── */
    /* ── (6) The texture really takes effect: **detach it and the picture must change** ── */
    /* ⚠ 夹具是**带贴图**的模型(BoomBox / Avocado)。这一条不成立就意味着贴图**没有真的到 GPU 上**。 */
    /* ⚠ The fixtures are **textured** (BoomBox / Avocado). If this fails, the textures never reached the GPU. */
    changed = 0;
    for (i = 0; i < GRID * GRID; i++) {
        int a; int b;
        a = grid_pixel(vp, sw, sh, i);
        b = grid_pixel(vp, sw, sh, i);
        if (differ(a, b)) changed = changed + 1;
    }
    /* 先证明读数**本身是稳的**(同一帧连读两次必须一样)—— 它是下面那条判据的前提。 */
    /* First prove the reading itself is **stable** (the same frame read twice must match) - it is the premise of the assertion below. */
    ck("readings are stable within one frame", changed == 0, changed, 0);

    /* 逐物体摘掉贴图,再整屏数一遍变了多少采样点 —— 至少得有一个物体是贴了图的。 */
    /* Detach per object and count the changed samples over the whole screen - at least one object must carry a texture. */
    /* ⚠⚠ **先把一整屏采下来,再摘,再采一遍**(2026-10-08 实测踩到):一边采一边摘是错的 —— */
    /* ⚠⚠ **Sample the whole screen first, then detach, then sample again** (measured 2026-10-08): sampling while */
    /* 摘掉之后就**装不回来**(没有"重新贴上"的入口),于是第二次采样全是"已经摘掉"的样子,恒等于自己。 */
    /* detaching is wrong - nothing puts a texture back, so every later sample already shows the detached look. */
    changed = 0;
    for (i = 0; i < n && i < 32; i++) {
        int obj; int k; int hit;
        obj = ui_3d_gltf_object_at(sc, i);
        for (k = 0; k < GRID * GRID; k++) g_before[k] = grid_pixel(vp, sw, sh, k);
        ui_3d_obj_set_tex(obj, 0);
        hit = 0;
        for (k = 0; k < GRID * GRID; k++) if (differ(g_before[k], grid_pixel(vp, sw, sh, k))) hit = hit + 1;
        if (hit > 0) { changed = changed + 1; printf("GLTF_TEX_HIT=%d obj=%d\n", hit, obj); }
    }
    printf("GLTF_TEX_OBJECTS=%d\n", changed);
    ck("detaching a texture changes the picture (texture reached the GPU)", changed >= 1, changed, 1);

    /* ── ⑦ 释放:**释放之后句柄必须失效** ─────────────────────────────── */
    /* ── (7) Free: **the handles must die with the scene** ──────────────── */
    i = ui_3d_gltf_free(sc);
    ck("gltf_free returned 0", i == 0, i, 0);
    ck("root handle is dead after free", ui_3d_gltf_root(sc) < 0, ui_3d_gltf_root(sc), -1);
    ck("object_count is dead after free", ui_3d_gltf_object_count(sc) < 0, ui_3d_gltf_object_count(sc), -1);

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

/* ══════════════════════════════════════════════════════════════════════════
 * 读数怎么读(写给下一个接手的人)
 * How to read the output (for whoever picks this up next)
 *
 * · `GLTF_FILE=` 装上了哪个 —— 空串 = 一个候选路径都不在(那是 SKIP,不是通过)。
 * · `GLTF_FILE=` which one loaded - an empty string means no candidate existed (a SKIP, not a pass).
 * · `GLTF_SCENE=` 句柄 / 物体数 / 根 / 包围球;**物体数就是"节点树展开成几个物体"**。
 * · `GLTF_SCENE=` handle / object count / root / bounding sphere; the **object count is how many objects the node tree expanded into**.
 *   BoomBox 是 3(根 + 1 节点 + 1 图元),层次深的模型更大。
 *   BoomBox gives 3 (root + one node + one primitive); deeper models give more.
 * · `GLTF_MOVED=` 移动根之后有几个孩子跟着动 —— 0 就是父子链没接上。
 * · `GLTF_MOVED=` how many children followed the root - zero means the parent links are missing.
 * · `GLTF_TEX_OBJECTS=` 摘掉贴图后画面真的变了的物体数 —— 0 就是贴图没到 GPU 上。
 * · `GLTF_TEX_OBJECTS=` how many objects changed the picture once detached - zero means no texture reached the GPU.
 * · 贴图降了多少(哪张、原始多大、降到多大)在 **stderr** 的 `[VML3D] gltf` 那几行里。
 * · How far each texture was downsampled (which one, from what size to what) is on **stderr**.
 *   ⚠ 那几行是中文的(`gltf 贴图「名字」…`),要 grep 就搜 `gltf`。
 *   ⚠ Those lines are in Chinese; grep for `gltf` to pick them up.
 * ==== end of notes ====
 */