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

c/3d/glbview.c · 28704 字节 · 下载原文

/* glbview.c —— **glb / gltf 查看器**。
 * glbview.c - a **glb / gltf viewer**.
 *
 * 用户口径(2026-10-08):「接口实现完成后,先做个 glb 查看器,然后再做个游戏把 glb 用上」。
 * User's call (2026-10-08): "once the interfaces are done, first make a glb viewer, then make a game that uses glb".
 *
 * ══ 怎么用 ══════════════════════════════════════════════════════════════════
 *   手机:命令行页敲 `vml run examples/c/3d/glbview.c`
 *   桌面:`dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll Examples/c/3d/glbview.c --screen 393x757`
 *
 *   单指拖 = 转视角       两指开合 = 拉近拉远
 *   A = 自动旋转          B = 换一个模型(候选里**装得上**的那些之间循环)
 *   X = 输出变换循环       Y = 阴影开关(`#642` 平面投影阴影)
 *   SELECT = PBR 开关(**默认开**)—— 金属度-粗糙度贴图(`#661`)只在它开着时才起作用
 *   ↑/↓ = 环境强度(金属反射的亮度)           R = 复位视角        Esc = 退出
 *
 * ⚠ 手机上这几个键来自**自带手柄**(方向键 + X/Y/A/B → Win32 虚拟键),程序里的键盘分支直接认。
 *
 * ══ 它**不**做什么(如实记下,别指望)════════════════════════════════════════
 * · **不列目录**:`vml_readdir` 在本仓宿主上**没有实现**(实测返回 -8,C# 侧零命中)⇒ 候选是
 *   写死的一串(见 CANDIDATES)。想看你自己的模型,照那几条路径放,或改那张表。
 * · **不播动画**:glTF 的 `animations` 还没解析,带骨骼的模型显示的是**绑定姿势**。
 *   ⚠ 蒙皮接口(`#656`–`#660`)已经能用,但**装载器还没解析 `skins`** ⇒ 这里也不解。
 * · 不支持 Draco / KTX2(装载器的已知缺口)。
 *
 * ══ 为什么值得单独立一个程序 ═════════════════════════════════════════════════
 * `#643`(glTF 2.0 静态装载)落地时的验收靠三个大模型 + 逐条跟浏览器对表,**重**。
 * 这个查看器把"装得上吗、画得对吗"压成一个到处能跑的程序:换模型、转视角、切伽马、开阴影,
 * 屏幕上按一下就有 —— 它同时也是 `#643`/`#649`/`#642` 的一个**人工冒烟口**。
 */

#include <vml_3d.h>
#include <vml_ui.h>
#include <math.h>
#include <stdio.h>

/* ══ 候选模型 ══════════════════════════════════════════════════════════════
 * 依次试;**第一个装得上的**就是开场那个,`B` 在"装得上的"之间循环。
 *
 * ⚠⚠ **相对路径是按「程序的所在目录」解析的**(2026-10-08 实测 —— **不是按 cwd**):
 *   本程序在 `<工作区>/examples/c/3d/`,所以**能命中的那条是 `models/xxx.glb`**(就近的那份);写成
 *   `examples/c/3d/models/xxx.glb` 会被拼成 `…/c/3d/examples/c/3d/models/xxx.glb`
 *   (宿主日志原话,以当时的夹具为例:`gltf_load:找不到 …(解析成 …/c/3d/examples/c/3d/models/Fox.glb)`)。
 *   手机与桌面是**同一套解析**(同一份共享层代码)⇒ 头一条就够,后面几条是兜底。
 * ⚠ 2026-10-09:`Fox.glb` **已从仓库删除**(它文件内自带 `asset.copyright` = **CC-BY 4.0**,要署名,
 *   而仓里当时记成 CC0 ⇒ 无署名随包分发)。随仓库进的换成 **`rover_cutout.glb`**(我们自生成,无第三方权利)。
 *   Avocado / BoomBox / ToyCar 在本机磁盘上(`models/.gitignore` 拦在仓库外、但**会进 vml_lib.zip**),
 *   DamagedHelmet 从未入库。许可与署名逐份见 `models/CREDITS.md`。
 * ⚠ 2026-10-10:**大模型全部删掉、换成小的**(用户:「例子不需要大文件,打包太大」)——
 *   `models/` 从 ~69 MB 降到 ~450 KB。删的是 >5 MB 的那几个 + 两个许可有问题的
 *   (`DamagedHelmet` CC BY-**NC** 禁商用 / `BoxTextured` 要署名带商标限制);
 *   换进来的是 Khronos 的 **CC0** 小样例(`AnimatedColorsCube` / `AnimatedMorphCube` /
 *   `BoxVertexColors` / `InterpolationTest` / `SimpleInstancing` / `TextureLinearInterpolationTest`)。
 *   ⚠ 2026-10-10 又换了一轮:`Duck`(Khronos README 写的是 **SCEA Shared Source License**,
 *   而本仓一直记成 CC0)与 `TextureCoordinateTest`(文件内 `asset.copyright` 写 **CC-BY 4.0**、
 *   README 却写 CC0 —— **两处冲突**)都**出局**了,见 `models/CREDITS.md`。
 *   逐份许可见 `models/CREDITS.md`。
 * ⚠ 2026-10-09: `Fox.glb` was **removed from the repo** ... see `CREDITS.md`.
 * ⚠ 2026-10-10: the big models were **dropped for small ones** (user: "examples don't need big files,
 *   the package is too big") - `models/` went from ~69 MB to ~450 KB. */
static const char* CANDIDATES[] = {
    /* ⚠⚠ **同一个相对路径在手机与桌面上指的不是同一个地方**(2026-10-08 实测,宿主日志原话):
     *   手机:`models/xxx.glb` → `/…/workspace/models/xxx.glb`        ← 基准 = **工作区根**
     *   桌面:`models/xxx.glb` → `/…/Examples/c/3d/models/xxx.glb`   ← 基准 = **程序目录**
     *   (手机那侧的规矩来自 Android 那条铁律:「一切路径以工作区为根、只按相对路径工作」。)
     * ⇒ 每个模型都写**两种拼法**,哪端命中哪端算 —— 只写一种的话,另一端会静默找不到、
     *   然后**悄悄退回下一个候选**(症状正是"我明明放了模型,它却打开的是别的")。
     *
     * 顺序 = 打开顺序(`B` 依次换)。**复杂的放前面**(用户口径:「再打开复杂一点的 glb」)。 */
    /* ⚠ 2026-10-10:换成**随包的小模型**(全部 CC0)——见文件头那条,理由与逐份许可在
     *   `models/CREDITS.md`。原先那些(DamagedHelmet/BoomBox/Avocado)已删(太大 / 许可)。
     *   ⚠ `MetalRoughSpheresNoTextures` 试过但**装不上**(102 个网格 > 引擎上限 64,实测
     *   `gltf_load:解析失败 —— 网格数 102 超过引擎上限 64`)⇒ 没进包。 */
    "models/AnimatedColorsCube.glb",                       /* 手机:<工作区>/models/… */
    "examples/c/3d/models/AnimatedColorsCube.glb",         /* 桌面:<程序目录>/models/… */
    "models/TextureLinearInterpolationTest.glb",
    "examples/c/3d/models/TextureLinearInterpolationTest.glb",
    "models/InterpolationTest.glb",
    "examples/c/3d/models/InterpolationTest.glb",
    "models/AnimatedMorphCube.glb",
    "examples/c/3d/models/AnimatedMorphCube.glb",
    "models/SimpleInstancing.glb",
    "examples/c/3d/models/SimpleInstancing.glb",
    "models/BoxVertexColors.glb",
    "examples/c/3d/models/BoxVertexColors.glb",
    /* ⚠ 随仓库进的那一份 = `rover_cutout.glb`(**本仓库自己生成**,无第三方权利)。
     *   2026-10-09 换的:原先这里兜底用的 `Fox.glb` 自带 `asset.copyright` = CC-BY 4.0(要署名),
     *   而仓里把它记成了 CC0 ⇒ 已删。逐份许可见 `models/CREDITS.md`。
     * ⚠ the one that **ships with the repo** is `rover_cutout.glb` (generated by us; no third-party rights). */
    "rover_cutout.glb",
    "models/rover_cutout.glb",
    "examples/c/3d/rover_cutout.glb",
    "third_party/vml/Examples/c/3d/rover_cutout.glb",
};
#define NCAND  ((int)(sizeof(CANDIDATES) / sizeof(CANDIDATES[0])))
#define MAXOK  16

/** 装得上的那些:候选表下标 + 显示名(只取路径最后一段,HUD 上放不下整条路径)。
 *  ⚠ 名字摊平成**一维**数组按 96 一段手算下标 —— **别用二维 `char[MAXOK][96]`**:
 *    实测这个 C 前端写进去读回来是空串(`printf` 打出 `(null)`),而**不报任何错**。 */
#define NAMELEN 96
static int  OKIDX[MAXOK];
static char OKNAME[MAXOK * NAMELEN];
static int  NOK;

/** 输出变换四档(`#649`):同一个场景在线性 / sRGB / Reinhard / ACES 下各长什么样。 */
static const char* GAMMA_NAME[] = { "线性 Linear", "sRGB", "Reinhard+sRGB", "ACES+sRGB" };

/** 底色五档。⚠ **白底那一档最容易看出模型自己的颜色对不对** —— 它是 sRGB 那套的判据载体。 */
static const int BGS[] = { 0xFF101018, 0xFF303038, 0xFF9A9AA2, 0xFFD8D8E0, 0xFF000000 };
#define NBG  ((int)(sizeof(BGS) / sizeof(BGS[0])))

/** `0xAARRGGBB` 的构造(`g3_all.c` 里那个同名助手的精简版)—— 只在这里用得上。 */
static int argb(int r, int g, int b) { return -16777216 + r * 65536 + g * 256 + b; }

/** 取路径最后一段(`a/b/c.glb` → `c.glb`)。 */
static void tail_name(const char* p, char* out, int cap) {
    int n; int i; int b;
    n = 0; while (p[n] != 0) n = n + 1;
    b = 0;
    for (i = 0; i < n; i = i + 1) if (p[i] == '/' || p[i] == '\\') b = i + 1;
    i = 0;
    while (p[b + i] != 0 && i < cap - 1) { out[i] = p[b + i]; i = i + 1; }
    out[i] = 0;
}

int main(void) {
    int sw; int sh; int vp; int i;
    int scene; int cur; int none; int bg; int gamma; int shadowOn; int autoOn; int pbrOn; int root;
    float envK;
    float camYaw; float camPitch; float camZoom; float modelR;
    int touch[4]; int t0[4]; int t1[4];
    int dragging; int pinching; int lastTx; int lastTy; float lastPinch;
    char hud[200];

    /* ⚠ 问尺寸必须用 **_ex**:`ui_scr_get_w()` 按**默认** chrome 估算,与本程序实际拿到的画布
     *   对不上 ⇒ 视口贴左上角(`solar3d.c` 实测踩过)。 */
    ui_win_open_ex("glb 查看器", 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 = 360;
    if (sh <= 0) sh = 640;

    vp = ui_3d_viewport_create(0, 0, sw, sh, 0);
    if (vp <= 0) {
        /* ⚠ **拿不到设备要如实报**:既不画、也不说,是最坏的一类失败。 */
        printf("NO_3D_DEVICE=1\n");
        return 0;
    }
    ui_3d_viewport_set_bg(vp, BGS[0]);
    /* 主光 + 一侧补光(`#637`):只有一盏时模型暗面是纯黑、看不出形状。 */
    ui_3d_viewport_set_light(vp, 0.45f, 0.85f, 0.35f, 0xFFFFFFFF, 0.30f);
    ui_3d_viewport_set_light_at(vp, 1, -0.6f, 0.30f, -0.5f, 0x40FFFFFF, 0.0f);
    /* ⚠⚠ **PBR 默认开着**(`#652`)。理由:glTF 的材质**天生是 PBR 的** —— 金属度/粗糙度那张图
     *   (`#661`)**只在 PBR 那条路上起作用**,关着 PBR 时它一个字节都进不了着色器 ⇒
     *   一个金属头盔会被画成**漫反射色块**(用户 2026-10-08 实测就是这么报的:
     *   「材质效果差很多了 / 玻璃贴图没出来」)。固定管线那条路是给**老程序**保底用的,
     *   不是给 glTF 用的。 */
    ui_3d_viewport_set_pbr(vp, 1);
    /* ⚠⚠ **环境强度也要一起设**(`#662`)。PBR 里**金属的亮度几乎全部来自环境反射**,
     *   而环境贴图是 8 位图(每通道封顶 1.0)、金属的 F0 又是暗色基色 ⇒ 只设 PBR 的话金属是
     *   "暗的镜子",看着发闷(用户 2026-10-08 口径:「还缺一些效果」「玻璃效果没有实现」)。
     *   实测把天空画亮之后黑软管/金属边条立刻有了正确反射 ⇒ **根因就是这个倍率**。
     *   ⚠ 真实渲染器靠 **HDR 环境贴图**(太阳几百、天空几);本仓没有,所以用倍率代替
     *     (与 three.js 的 `scene.environmentIntensity` 同一个旋钮)。 */
    envK = 1.8f;
    ui_3d_viewport_set_env_intensity(vp, envK);

    /* ── 探一遍候选 ────────────────────────────────────────────────────────
     * ⚠ `ui_3d_gltf_load` 是"**边解析边建物体**",没有"只解析不建"的形态 ⇒ 试一条就得**立刻释放**
     *   (不释放的话每试一条,屏幕上就多叠一份模型)。
     * ⚠ 这一趟的代价 = 每个**真的存在**的候选解析一次;不存在的(大多数)在打开文件那一步就
     *   失败了,几乎不要钱。 */
    /* ⚠ **第一个装上的直接留着用**,后面探到的立刻还回去 —— 这样每个模型只解析一次
     *   (探路 + 正式装 = 两趟的写法会让启动慢一倍,实测 Fox 那 163 KB 要几秒)。 */
    NOK = 0; scene = 0; cur = -1;
    for (i = 0; i < NCAND && NOK < MAXOK; i = i + 1) {
        int s;
        s = ui_3d_gltf_load(vp, (char*)CANDIDATES[i]);
        if (s <= 0) continue;
        if (NOK == 0) scene = s; else ui_3d_gltf_free(s);
        OKIDX[NOK] = i;
        tail_name(CANDIDATES[i], &OKNAME[NOK * NAMELEN], NAMELEN);
        NOK = NOK + 1;
    }
    if (NOK > 0) cur = 0;
    none = (NOK == 0 || scene <= 0);
    if (none) { scene = 0; cur = -1; }
    printf("GLBVIEW models=%d first=%s\n", NOK,
           none ? "(none)" : &OKNAME[(cur < 0 ? 0 : cur) * NAMELEN]);
    if (none) {
        /* 一个都没装上:**说清楚试过哪些**,别留一块空屏让人猜。 */
        printf("NO_MODEL=1\n");
        for (i = 0; i < NCAND; i = i + 1) printf("  tried: %s\n", CANDIDATES[i]);
    }

    /* ── 程序化天空(**金属要靠它才不发黑**)─────────────────────────────────
     * ⚠⚠ 这是本仓的一条硬事实:PBR 的金属**没有漫反射**,全部能量走镜面 —— 而镜面要有东西可反射。
     *   `DamagedHelmet` 的面罩是 `metalness = 1`,视口**没设天空盒**时环境反射那段一条指令都不走
     *   ⇒ 读对了金属度贴图**反而更暗**(实测:一整块黑疙瘩)。真实查看器靠 IBL 照环境,这里靠
     *   `#640` 那张天空盒(本仓的环境反射是"按反射方向采天空盒 + 按粗糙度挑 mip 级"的近似)。
     * ⇒ 画一块**竖直渐变** → 用 `ui_2d_get_image` 抓成图像 → 当天空盒(等距圆柱)。
     *   ⚠ 抓的是**当时 2D 画布上的内容**,所以必须在画 HUD **之前**抓、抓完清屏。 */
    {
        int band; int skyH; int sky; int horizon;
        skyH = sh / 2;
        horizon = skyH * 45 / 100;                     /* 地平线放在 45% 高度处 */
        ui_2d_clear(0);
        /* ⚠⚠ **环境要有结构**,不能只是一条渐变:金属**没有漫反射**,它的样子**完全**来自
         *   反射到的东西 —— 一片纯渐变天空反到抛光面罩上,看起来就是**一块发闷的平色**
         *   (用户 2026-10-08 实测:「还缺一些效果」)。给天空加**地平线 + 地面 + 一个太阳**
         *   之后,同一块面罩上立刻能看出"天在上、地在下的弧"和那个亮点 ⇒ **一眼就是镜子**。
         *   (真实查看器靠 HDRI 环境贴图,这里用几笔画一个"认得出的世界"。) */
        for (band = 0; band < 96; band = band + 1) {
            int y; int t; int r0; int g0; int b0;
            y = band * skyH / 96;
            if (y < horizon) {
                /* 上半:天顶深蓝 → 地平线灰白(地平线附近最亮) */
                t = (horizon > 0) ? (y * 255 / horizon) : 0;
                r0 = 40 + (205 - 40) * t / 255;
                g0 = 62 + (215 - 62) * t / 255;
                b0 = 110 + (228 - 110) * t / 255;
            } else {
                /* 下半:**地面**(暖灰,比天空暗一截)—— 没有它金属反射就只有"上面亮下面也亮" */
                /* ⚠⚠ **地面要压得比天空暗很多** —— "玻璃/金属"的观感来自**对比**(亮的天 + 暗的地
                 *   在抛光面上切开一条清晰的地平线),而不是来自整体亮度。地面和天空一样亮的话,
                 *   反射出来还是一块平色(前面几版就是这么"提不亮也压不暗"的)。 */
                t = (skyH - 1 - horizon > 0) ? ((y - horizon) * 255 / (skyH - 1 - horizon)) : 0;
                r0 = 46 - (46 - 12) * t / 255;
                g0 = 47 - (47 - 13) * t / 255;
                b0 = 50 - (50 - 16) * t / 255;
            }
            ui_2d_rect(0, y, sw, skyH / 96 + 1, argb(r0, g0, b0), 1, 0, 0);
        }
        /* ── 让环境**有高频结构**(这一条决定"像不像玻璃")──────────────────────
         * ⚠⚠ 只画一条**竖直渐变**的话,光滑球面上反射出来**还是一条渐变** —— 看着就是
         *   "一块平色 + 没有光泽"(用户 2026-10-08 反复说的「玻璃效果没出来」)。
         *   真查看器靠 **HDRI 环境贴图**(天空、云、建筑、地面,全是有形状的东西),
         *   弯曲的镜面把那堆形状**扭出可辨认的轮廓**,大脑立刻读成"玻璃"。
         * ⇒ 这里用几笔画一个有形状的世界:**太阳 + 云 + 地平线亮带 + 暗地面**。
         *   ⚠ 顺序要紧:先地面/天空(底),再云,最后太阳(压在最上层)。 */
        {
            int k;
            /* 地平线亮带:抛光面上那条最亮的水线就是它 */
            ui_2d_rect(0, horizon - skyH / 40, sw, skyH / 20, argb(255, 248, 232), 1, 0, 0);
            /* 几朵"云":圆角矩形 = 有形状的亮块(半径给到一半高就够圆了) */
            for (k = 0; k < 5; k = k + 1) {
                int cx; int cy; int cw; int ch;
                /* ⚠⚠ **云要贴近地平线**:面罩(抛光面)反射到的主要是**地平线上下那一带**,
                 *   云画在天顶附近的话,镜面里**什么都反射不到** ⇒ 还是一块平色。 */
                cx = (sw * (12 + k * 19)) / 100;
                cy = horizon * (56 + ((k % 3) * 13)) / 100;
                cw = sw / (7 + (k % 3));
                ch = skyH / (16 + (k % 4));
                ui_2d_rect(cx, cy, cw, ch, argb(232, 236, 244), 1, 0, ch / 2);
                ui_2d_rect(cx + cw / 4, cy + ch / 2, cw * 3 / 5, ch * 2 / 3,
                           argb(214, 220, 232), 1, 0, ch / 3);
            }
            /* 地面上的几块暗斑:让"下半"也有形状(否则金属的下半球还是一块平色) */
            for (k = 0; k < 4; k = k + 1) {
                int gx; int gy; int gw; int gh;
                gx = (sw * (8 + k * 26)) / 100;
                gy = horizon + (skyH - horizon) * (20 + ((k % 2) * 40)) / 100;
                gw = sw / 5; gh = skyH / 18;
                ui_2d_rect(gx, gy, gw, gh, argb(38, 40, 44), 1, 0, gh / 2);
            }
            /* 太阳(最亮的那一点)—— 抛光面上那记高光就是它;偏左上,转起来才有变化 */
            /* ⚠ **太阳要压到地平线附近**(夕阳位):低太阳在抛光面上拉出一条**又长又亮**的反射,
             *   那是"金属/玻璃"最强的视觉信号;画在高空只会反射不到。 */
            ui_2d_rect(sw * 24 / 100, horizon - skyH / 12, sw / 7, skyH / 8, argb(255, 252, 238), 1, 0, 0);
            ui_2d_rect(sw * 20 / 100, horizon - skyH / 9, sw / 5, skyH / 6, argb(196, 204, 216), 1, 0, sw / 14);
        }
        sky = ui_2d_get_image(0, 0, sw, skyH);
        ui_2d_clear(0);
        if (sky > 0) ui_3d_viewport_set_skybox(vp, sky);
    }

    /* ── 自动取景:**先把模型归一化到固定大小**,再按这个固定半径取景 ──────────
     * ⚠ 模型的大小是**数据**(Avocado 是 ±0.01、Fox 是 87.7、头盔是 1.6),而相机距离是**屏幕的函数**
     *   ⇒ 半径必须问 `#648` 要,不能在程序里硬写。
     * ⚠⚠ **归一化不只是为了好看**:天空盒的半径是宿主常量(180 世界单位),而相机距离由 `fit`
     *   按半径推 —— 一个半径 87.7 的模型会把相机推到 **~175**,离 180 只剩一点点余量;
     *   再大一点模型就跑到天空盒**外面**、被盒子的后壁挡住("模型突然不见了")。
     *   统一缩到 10 之后相机约 23,任何模型都稳稳地在盒子里。 */
    modelR = 10.0f;
    root = 0;
    if (scene > 0) {
        float bnd[4]; float r; float s;
        bnd[0] = 0.0f; bnd[1] = 0.0f; bnd[2] = 0.0f; bnd[3] = 0.0f;
        r = 1.0f;
        if (ui_3d_gltf_bounds(scene, (int)&bnd[0]) == 0 && bnd[3] > 0.0f) r = bnd[3];
        s = 10.0f / r;
        root = ui_3d_gltf_root(scene);
        if (root > 0) ui_3d_obj_set_scale(root, s);
        modelR = r;                                /* HUD 上显示**原始**半径(那是数据的样子) */
    }
    ui_3d_viewport_fit(vp, 11.5f);

    camYaw = 30.0f; camPitch = 18.0f; camZoom = 1.0f;
    autoOn = 1; gamma = 0; bg = 0; shadowOn = 0; pbrOn = 1;
    dragging = 0; pinching = 0; lastTx = 0; lastTy = 0; lastPinch = 0.0f;

    while (ui_win_is_closed() == 0) {
        int k;

        /* ⚠ 自动旋转**累加到 yaw 上**(不是给物体设 `#623` 自转)—— 转的是**相机**,
         *   于是模型背后那一面也能看到,而且不需要知道模型的朝向。 */
        if (autoOn) camYaw = camYaw + 0.35f;
        if (camYaw > 360.0f) camYaw = camYaw - 360.0f;

        /* ── 单指拖 = 转视角 ───────────────────────────────────────────────
         * 用「本帧 − 上一帧」的**增量**累加,不用绝对位移:手指从边缘重新落下时绝对量会跳。 */
        if (ui_touch_read(0, touch)) {
            if (touch[2]) {
                if (dragging) {
                    /* ⚠ **横向取负号**:`set_orbit` 的 yaw 是球坐标标准式(与 three.js 的
                     *   `OrbitControls` 同一条),而那边拖右是"把画面往右带" ⇒ θ 减小。
                     *   竖直不用翻:pitch 增大让相机升高、画面跟着手指往下走。 */
                    camYaw = camYaw - (touch[0] - lastTx) * 0.5f;
                    camPitch = camPitch + (touch[1] - lastTy) * 0.5f;
                    if (camPitch > 88.0f) camPitch = 88.0f;
                    if (camPitch < -88.0f) camPitch = -88.0f;
                }
                dragging = 1; lastTx = touch[0]; lastTy = touch[1];
            } else dragging = 0;
        } else dragging = 0;

        /* ── 两指开合 = 缩放 ───────────────────────────────────────────────
         * 用**两指间距的比值**累乘(同"增量"那条理由)。⚠ 比值要挡小值:两指几乎重合时
         * `lastPinch` 接近 0 ⇒ 除零得 inf ⇒ 画面直接没了。 */
        if (ui_touch_read(0, t0) && ui_touch_read(1, t1) && t0[2] && t1[2]) {
            float ddx; float ddy; float dd;
            ddx = (float)(t0[0] - t1[0]);
            ddy = (float)(t0[1] - t1[1]);
            dd = (float)sqrt((double)(ddx * ddx + ddy * ddy));
            if (pinching && dd > 8.0f && lastPinch > 8.0f) {
                camZoom = camZoom * (lastPinch / dd);
                if (camZoom < 0.15f) camZoom = 0.15f;
                if (camZoom > 6.0f) camZoom = 6.0f;
            }
            lastPinch = dd; pinching = 1;
        } else pinching = 0;
        ui_3d_viewport_set_zoom(vp, camZoom);
        ui_3d_viewport_set_orbit(vp, camYaw, camPitch);

        /* ── 键盘(手机上来自自带手柄)───────────────────────────────────── */
        if (ui_key_is_down(VML_KEY_PAD_A)) autoOn = 1 - autoOn;
        if (ui_key_is_down(VML_KEY_SELECT)) {
            pbrOn = 1 - pbrOn;
            ui_3d_viewport_set_pbr(vp, pbrOn);
        }
        if (ui_key_is_down(VML_KEY_PAD_Y)) {
            shadowOn = 1 - shadowOn;
            ui_3d_viewport_set_shadow(vp, shadowOn);
        }
        if (ui_key_is_down(VML_KEY_PAD_X)) {
            gamma = gamma + 1; if (gamma > 3) gamma = 0;
            ui_3d_viewport_set_gamma(vp, gamma);
        }
        /* ↑/↓ = 环境强度(`#662`)。⚠ 这两个键原来调**背景色**,而天空盒(`#640`)一开就把背景
         *   **整个盖住**了 ⇒ 它们成了**死键**(按下去画面纹丝不动)。
         *   改成调环境强度 —— 这一屏上**最值得调**的就它(金属亮不亮全看这个数)。 */
        if (ui_key_is_down(VML_KEY_UP)) {
            envK = envK + 0.4f; if (envK > 8.0f) envK = 8.0f;
            ui_3d_viewport_set_env_intensity(vp, envK);
        }
        if (ui_key_is_down(VML_KEY_DOWN)) {
            envK = envK - 0.4f; if (envK < 0.0f) envK = 0.0f;
            ui_3d_viewport_set_env_intensity(vp, envK);
        }
        if (ui_key_is_down(82)) {                 /* 'R' —— 复位视角 */
            camYaw = 30.0f; camPitch = 18.0f; camZoom = 1.0f;
        }
        /* B = 换模型:释放当前、装下一个能装上的 */
        if (ui_key_is_down(VML_KEY_PAD_B) && NOK > 1) {
            int nxt; int s; int nroot;
            nxt = cur + 1; if (nxt >= NOK) nxt = 0;
            s = ui_3d_gltf_load(vp, (char*)CANDIDATES[OKIDX[nxt]]);
            if (s > 0) {
                if (scene > 0) ui_3d_gltf_free(scene);   /* ⚠ 先装新的、装上了再放旧的:
                                                          *   反过来的话新模型装失败就**全空**了 */
                scene = s; cur = nxt;
                {
                    float bnd[4]; float r; float s;
                    bnd[3] = 0.0f;
                    r = 1.0f;
                    if (ui_3d_gltf_bounds(scene, (int)&bnd[0]) == 0 && bnd[3] > 0.0f) r = bnd[3];
                    s = 10.0f / r;
                    nroot = ui_3d_gltf_root(scene);
                    if (nroot > 0) ui_3d_obj_set_scale(nroot, s);
                    modelR = r;
                }
                ui_3d_viewport_fit(vp, 11.5f);
                camZoom = 1.0f;
            }
        }

        /* ── HUD:2D 画在 3D **之上** ─────────────────────────────────────
         * `ui_2d_clear` 用全透明 —— 出图时 3D 那一层是底、2D 图元直接叠上去。 */
        ui_2d_clear(0);
        ui_2d_text(14, 12, "glb 查看器", -1, 17, VML_ANCHOR_LEFT);
        if (none) {
            ui_2d_text(14, 40, "没有找到模型 —— 把 .glb 放到 examples/models/ 再跑", -65536, 12,
                       VML_ANCHOR_LEFT);
        } else {
            int n;
            n = ui_3d_gltf_object_count(scene);
            sprintf(hud, "%s   物体 %d   半径 %.1f", &OKNAME[cur * NAMELEN], n, (double)modelR);
            ui_2d_text(14, 40, hud, -8355712, 12, VML_ANCHOR_LEFT);
        }
        sprintf(hud, "%s%s   PBR%s   阴影%s   环境 ×%.1f",
                GAMMA_NAME[gamma], autoOn ? "   自动旋转" : "", pbrOn ? "开" : "关",
                shadowOn ? "开" : "关", (double)envK);
        ui_2d_text(14, 60, hud, -8355712, 12, VML_ANCHOR_LEFT);
        ui_2d_text(14, 80, "拖动旋转 · 两指缩放 · A 自转 · B 换模型 · X 伽马 · Y 阴影 · SELECT PBR · ↑↓ 环境 · R 复位",
                   -8355712, 11, VML_ANCHOR_LEFT);
        ui_2d_present();

        /* ⚠ 这一句只是"等输入、最多等这么久";**上屏节奏由宿主管**
         *   (`ui_2d_present` 出来时宿主已经跟着显示走了一帧)—— 别靠改这个数去调帧率。 */
        k = ui_msg_fetch_wait(40);
        (void)k;
    }

    if (scene > 0) ui_3d_gltf_free(scene);
    ui_3d_viewport_free(vp);
    return 0;
}