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 ====
*/