c/3d/normalmap_demo.c · 22167 字节 · 下载原文
/* normalmap_demo.c —— **切线空间法线贴图**(`ui_3d_obj_set_normal_map` `#639`)专项示例 + 判据。
* normalmap_demo.c — **tangent-space normal mapping** (`ui_3d_obj_set_normal_map` `#639`): example and criteria.
*
* 同一块贴了岩石纹理的板,比较三种状态下的**光照**:关掉法线图、中性法线图、扰动的法线图。
* One rock-textured quad is lit three ways: no normal map, a neutral normal map, and a perturbed one.
*
* 中性图 `(128,128,255)` 对应切线空间 `(0,0,1)` ⇒ 数学上是**恒等**,画面必须几乎不变。
* A neutral map `(128,128,255)` is tangent-space `(0,0,1)`, i.e. the **identity**, so the image must barely change.
*
* 扰动图(比如整体偏 +X)⇒ 同一批采样点的亮度**必须变**。
* A perturbed map (tilted towards +X, say) must **change** the brightness of the same sample points.
*
* ## 为什么这份判据非写不可
* ## Why this criterion has to exist
*
* ⚠ 这个接口有一条**会静默失效**的路:TBN 由**屏幕空间导数**就地推出,**uv 全 0 时 det = 0**,实现整个跳过、退回几何法线,且一个错都不报。
* ⚠ There is a **silent** failure path: the TBN comes from **screen-space derivatives**, and with all-zero uv det = 0, so the implementation skips itself, falls back to the geometric normal, and reports nothing.
*
* 症状是「贴了法线图跟没贴一模一样」—— 与「法线图本来就没效果」读出来完全一样。
* The symptom is "the normal map changes nothing", which reads identically to "a normal map has no effect".
*
* ⇒ 所以本文件用**四角 uv 互不相同**的网格,并额外钉一条**见证判据**:uv 全 0 的网格上贴与不贴确实没区别。
* => So this file uses a grid with **four distinct corner uvs** and pins a **witness criterion**: on an all-zero-uv grid, mapped and unmapped really are identical.
*
* 那条见证判据把**已知限制**固定下来,哪天实现改了、它会立刻变红并指回这里。
* That witness criterion freezes the **known limitation**, so the day the implementation changes it turns red and points back here.
*
* ## 跑法
* ## How to run
* dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll \
* third_party/vml/Examples/c/3d/normalmap_demo.c --screen 393x757
*
* 机器判读:逐行 `PASS …` / `FAIL …`,末行 `NORMALMAP_DEMO PASS|FAIL (fails=N)`。
* Machine-readable: one `PASS ...` / `FAIL ...` line each, ending with `NORMALMAP_DEMO PASS|FAIL (fails=N)`.
*/
#include <vml_ui.h>
#include <vml_3d.h>
#include <math.h>
#include <stdio.h>
#include <file.h>
/* ══ 记分 ══════════════════════════════════════════════════════════════════ */
/* == Scoring == */
int g_fails;
/* 判据除了打 stdout,还写一份**结果文件**。
* Besides stdout, every criterion is also written to a **result file**.
*
* ⚠ 手机端跑开窗程序时命令行页会被绘图页顶掉,驱动"看到窗口就算跑完" ⇒ 只抓屏会漏掉判据正文。
* ⚠ On a phone a window-opening program hides the command-line page and the harness counts "window appeared" as done, so scraping the screen misses the criteria.
*
* 结果文件落在 cwd(手机上是工作区根),`adb pull` 直接取得到。
* The file lands in cwd (the workspace root on a phone) and is directly pullable with `adb pull`. */
char RLINE[192];
/* ⚠ 句柄 **0 是合法值**(实测 `fopen` 成功返回 0)⇒ 判"开没开成"必须用**负值**。
* ⚠ Handle **0 is valid** (measured: a successful `fopen` returns 0), so "did it open" must test for a **negative** value. */
int g_rpt = -2; /* -2 = 还没试过,>= 0 = 已打开,< 0 = 开不了 */
void rpt_line(char* s) {
int n;
if (g_rpt == 0 - 2) g_rpt = fopen("_3d_normalmap_result.txt", 1);
if (g_rpt < 0) return;
n = 0;
while (s[n] != 0) n = n + 1;
fwrite(g_rpt, s, n);
}
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); }
sprintf(RLINE, "%s %s %d %d\n", cond ? "PASS" : "FAIL", tag, a, b);
rpt_line(RLINE);
}
int rch(int c) { return (c >> 16) & 0xFF; }
int gch(int c) { return (c >> 8) & 0xFF; }
int bch(int c) { return c & 0xFF; }
int argb(int r, int g, int b) { return -16777216 + r * 65536 + g * 256 + b; }
/* ══ 网格装配(pos3 + nrm3 + uv2 = 8 个 float32,索引 int32 紧随其后)══════════ */
/* == Mesh assembly (pos3 + nrm3 + uv2 = 8 float32, then int32 indices) == */
/* 容量按最大那次生成算:`gen_quad_sub(12,…)` = 13×13 顶点 × 8 + 12×12×6 索引 = 2216。
* Sized for the largest generation: gen_quad_sub(12,...) = 13x13 vertices x 8 + 12x12x6 indices = 2216. */
/* ⚠ 从前这里写 512(远小于 2216)⇒ 索引写到数组外面,把后面的全局变量整片写烂。
* ⚠ This used to say 512 (far below 2216), so indices wrote past the array and smashed the globals after it. */
/* 症状是判据正文变成一串**内存乱码**,而退出码照样是 0 —— 「退出码不是判据」的活标本。
* The symptom is the criteria turning into **memory garbage** while the exit code stays 0, a live specimen of "the exit code is not a criterion". */
#define MB_CAP 4096
float MB[MB_CAP];
int g_vn;
int g_in;
void mb_vertex_uv(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 subdivided quad: the subdivision makes the screen-space derivatives **vary** across the quad.
*
* ⚠ 一块大方片只有一个 uv 常量斜率时 det 仍有效(不为 0),但细分后法线贴图的凹凸在光照上更容易看出来,也更接近真实表面。
* ⚠ With one big quad the uv slope is constant and det is still valid, but subdividing makes the bump detail visible in the lighting and closer to a real surface. */
void gen_quad_sub(int n, float half, float uv_repeat) {
int i; int j; int v; int k;
v = 0;
for (j = 0; j <= n; j = j + 1) {
for (i = 0; i <= n; i = i + 1) {
float fx = 0 - half + 2.0f * half * i / n;
float fy = 0 - half + 2.0f * half * j / n;
mb_vertex_uv(v, fx, fy, 0.0f, 0.0f, 0.0f, 1.0f,
uv_repeat * i / n, uv_repeat * j / n);
v = v + 1;
}
}
g_vn = v;
k = 0;
for (j = 0; j < n; j = j + 1) {
for (i = 0; i < n; i = i + 1) {
int a = j * (n + 1) + i;
int b = a + n + 1;
mb_index(k, a); k = k + 1;
mb_index(k, a + 1); k = k + 1;
mb_index(k, b); k = k + 1;
mb_index(k, a + 1); k = k + 1;
mb_index(k, b + 1); k = k + 1;
mb_index(k, b); k = k + 1;
}
}
g_in = k;
}
/* 同一块方片,但 **uv 全 0** —— 专门用来做"见证判据"(已知限制)。
* The same quad with **all-zero uv**, used for the witness criterion (the known limitation). */
void gen_quad_uv0(int n, float half) {
int i; int j; int v; int k;
v = 0;
for (j = 0; j <= n; j = j + 1) {
for (i = 0; i <= n; i = i + 1) {
float fx = 0 - half + 2.0f * half * i / n;
float fy = 0 - half + 2.0f * half * j / n;
mb_vertex_uv(v, fx, fy, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f);
v = v + 1;
}
}
g_vn = v;
k = 0;
for (j = 0; j < n; j = j + 1) {
for (i = 0; i < n; i = i + 1) {
int a = j * (n + 1) + i;
int b = a + n + 1;
mb_index(k, a); k = k + 1;
mb_index(k, a + 1); k = k + 1;
mb_index(k, b); k = k + 1;
mb_index(k, a + 1); k = k + 1;
mb_index(k, b + 1); k = k + 1;
mb_index(k, b); k = k + 1;
}
}
g_in = k;
}
/* ══ 采样:在物体自报的屏幕位置周围取 5×5 个点 ══════════════════════════════ */
/* == Sampling: a 5x5 grid around the screen position the object itself reports == */
/* ⚠ `ui_3d_viewport_get_pixel` 是**逐点一趟线程往返**(实测 6561 次 = 33 秒)⇒ 必须抽稀。
* ⚠ `ui_3d_viewport_get_pixel` is one thread round-trip per point (6561 points measured 33 seconds). */
int SA[64];
int SB[64];
int SN;
void grab(int* dst, int vp, int cx, int cy, int rad) {
int i; int j; int n; n = 0;
for (j = -2; j <= 2; j = j + 1)
for (i = -2; i <= 2; i = i + 1) {
dst[n] = ui_3d_viewport_get_pixel(vp, cx + i * rad / 2, cy + j * rad / 2);
n = n + 1;
}
SN = n;
}
/* 两组采样里**单个通道**的最大差值(0 = 逐通道完全相同)。
* The largest single-channel difference between two sample sets (0 = identical channel by channel). */
int max_delta(int* a, int* b) {
int k; int m; int d; m = 0;
for (k = 0; k < SN; k = k + 1) {
d = rch(a[k]) - rch(b[k]); if (d < 0) d = 0 - d; if (d > m) m = d;
d = gch(a[k]) - gch(b[k]); if (d < 0) d = 0 - d; if (d > m) m = d;
d = bch(a[k]) - bch(b[k]); if (d < 0) d = 0 - d; if (d > m) m = d;
}
return m;
}
int all_identical(int* a, int* b) {
int k;
for (k = 0; k < SN; k = k + 1) if (a[k] != b[k]) return 0;
return 1;
}
/* ══ 程序化图像 ═════════════════════════════════════════════════════════════ */
/* == Procedural images == */
#define IMG_PX 32
#define IMG_X 8
#define IMG_Y 8
/* 一块"岩石"高度场:几个正弦叠起来,确定性、无需随机数。
* A "rock" height field: a few summed sines, deterministic, no RNG. */
float rock_h(int x, int y) {
float fx = x * 0.36f;
float fy = y * 0.36f;
return 0.55f * sin(fx) * cos(fy * 0.8f)
+ 0.30f * sin(fx * 2.3f + 1.1f) * cos(fy * 1.7f + 0.4f)
+ 0.15f * sin(fx * 4.7f + 2.3f) * cos(fy * 3.9f + 1.7f);
}
/* 岩石的底色(灰度带一点暖调)—— 让画面像石头而不是纯白。
* The rock albedo (grey with a warm tint) so the surface reads as stone rather than plain white. */
void draw_rock_albedo(void) {
int x; int y;
for (y = 0; y < IMG_PX; y = y + 1)
for (x = 0; x < IMG_PX; x = x + 1) {
float h = rock_h(x, y);
int v = (int)(150.0f + 70.0f * h);
if (v < 40) v = 40; if (v > 235) v = 235;
ui_2d_rect(IMG_X + x, IMG_Y + y, 1, 1, argb(v, (int)(v * 0.96f), (int)(v * 0.88f)), 1, 0, 0);
}
}
/* 从**高度图**推切线空间法线贴图:Sobel 差分 → 归一化 → 映射到 RGB。
* Derive a tangent-space normal map from the **height field**: Sobel differences, normalise, map to RGB.
*
* 这就是"更接近真实"里最常缺的一块 —— 现成的例程里没有从高度图推法线的代码。
* This is the piece most often missing for realism: no existing example derives normals from a height map. */
void draw_rock_normal(void) {
int x; int y;
for (y = 0; y < IMG_PX; y = y + 1)
for (x = 0; x < IMG_PX; x = x + 1) {
float hl = rock_h((x + IMG_PX - 1) % IMG_PX, y);
float hr = rock_h((x + 1) % IMG_PX, y);
float hd = rock_h(x, (y + IMG_PX - 1) % IMG_PX);
float hu = rock_h(x, (y + 1) % IMG_PX);
float nx = 0 - (hr - hl) * 0.9f;
float ny = 0 - (hu - hd) * 0.9f;
float nz = 1.0f;
float len = sqrt(nx * nx + ny * ny + nz * nz);
int r = (int)(127.5f + 127.0f * nx / len);
int g = (int)(127.5f + 127.0f * ny / len);
int b = (int)(127.5f + 127.0f * nz / len);
if (r < 0) r = 0; if (r > 255) r = 255;
if (g < 0) g = 0; if (g > 255) g = 255;
if (b < 0) b = 0; if (b > 255) b = 255;
ui_2d_rect(IMG_X + x, IMG_Y + y, 1, 1, argb(r, g, b), 1, 0, 0);
}
}
/* 单色填充(做"中性"与"整体偏转"两张对照图)。
* A flat fill, used for the neutral and uniformly-tilted reference maps. */
void draw_flat(int r, int g, int b) {
ui_2d_rect(IMG_X, IMG_Y, IMG_PX, IMG_PX, argb(r, g, b), 1, 0, 0);
}
int main(void) {
int w; int h; int vp;
int m_quad; int m_uv0;
int o_quad; int o_uv0;
int img_albedo; int img_neutral; int img_tilt; int img_rock;
int r; int px; int py; int rad; int sc;
int d_neutral; int d_tilt;
int lang;
g_fails = 0;
lang = ui_scr_get_language();
ui_win_open_ex(lang == 0 ? "3D 法线贴图示例" : "3D Normal Map Demo",
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\n", w, h);
if (w < 64 || h < 64) { printf("NORMALMAP_DEMO FAIL (fails=99)\n"); return 99; }
vp = ui_3d_viewport_create(0, 0, w, h, 0);
ck("viewport -> handle", vp > 0, vp, 0);
/* 相机正对方片。光从左上前方来 ⇒ 法线一旦被扰动,明暗立刻跟着变。
* Camera facing the quad; the light comes from upper-left-front so any normal perturbation shows at once. */
ui_3d_viewport_set_camera(vp, 0.0f, 0.0f, 7.0f, 0.0f, 0.0f, 0.0f);
ui_3d_viewport_set_fov(vp, 55.0f, 0.1f, 100.0f);
ui_3d_viewport_set_bg(vp, argb(14, 16, 20));
ui_3d_viewport_set_light(vp, 0 - 0.55f, 0.62f, 0.56f, argb(255, 250, 240), 0.18f);
ui_3d_viewport_set_light_at(vp, 1, 0.5f, 0.3f, 0.6f, argb(120, 150, 210), 0.10f);
/* ── 图像:底色 + 三张法线图 ──────────────────────────────────────────── */
/* == Images: the albedo plus three normal maps == */
draw_rock_albedo();
ui_2d_present();
img_albedo = ui_2d_get_image(IMG_X, IMG_Y, IMG_PX, IMG_PX);
ck("albedo image -> handle", img_albedo > 0, img_albedo, 0);
/* 中性:切线空间 (0,0,1) ⇒ 恒等,画面必须几乎不变。
* Neutral: tangent-space (0,0,1), the identity, so the image must barely change. */
draw_flat(128, 128, 255);
ui_2d_present();
img_neutral = ui_2d_get_image(IMG_X, IMG_Y, IMG_PX, IMG_PX);
ck("neutral normal image -> handle", img_neutral > 0, img_neutral, 0);
/* 整体朝 +X 偏转:明暗必须变。
* Uniformly tilted towards +X: the shading must change. */
draw_flat(255, 128, 128);
ui_2d_present();
img_tilt = ui_2d_get_image(IMG_X, IMG_Y, IMG_PX, IMG_PX);
ck("tilted normal image -> handle", img_tilt > 0, img_tilt, 0);
/* 从高度图推出来的岩石法线图 —— 这是"接近真实"真正要用的那一张。
* The rock normal map derived from the height field: the one realism actually wants. */
draw_rock_normal();
ui_2d_present();
img_rock = ui_2d_get_image(IMG_X, IMG_Y, IMG_PX, IMG_PX);
ck("derived rock normal image -> handle", img_rock > 0, img_rock, 0);
/* ⚠⚠ 抓完图必须把 2D 层清成透明:3D 视口是**透过 2D 层**看的,不清就是一片黑。
* ⚠⚠ Clear the 2D layer once the grabs are done: the 3D viewport shows **through** it, and an unclean layer is a black screen. */
ui_2d_clear(0);
ui_2d_present();
/* ── 网格与物体 ──────────────────────────────────────────────────────── */
/* == Meshes and objects == */
gen_quad_sub(12, 3.2f, 3.0f);
/* 先量缓冲够不够 —— 算小了**不报错**,只是把内存写烂,判据会以"莫名其妙"的形式变红。
* Check the buffer first: too small reports nothing, it just smashes memory and the criteria fail inexplicably. */
ck("mesh buffer fits (quad)", g_vn * 8 + g_in <= MB_CAP, g_vn * 8 + g_in, MB_CAP);
m_quad = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
ck("quad mesh -> handle", m_quad > 0, m_quad, 0);
o_quad = ui_3d_obj_create(vp, m_quad);
ck("quad object -> handle", o_quad > 0, o_quad, 0);
/* 物体色设**白** —— 贴图是**乘性** tint,不设白的话读到的颜色是"物体色 × 纹素"。
* The object colour is **white**: the texture is a **multiplicative** tint, otherwise you read colour x texel. */
ui_3d_obj_set_color(o_quad, argb(255, 255, 255));
r = ui_3d_obj_set_tex(o_quad, img_albedo);
ck("set_tex(albedo) rc", r == 0, r, 0);
sc = ui_3d_obj_get_screen(o_quad);
px = sc & 0xFFFF; py = (sc >> 16) & 0xFFFF;
rad = w / 8;
printf("OBJSCREEN=%d,%d rad=%d\n", px, py, rad);
ck("quad centre on screen", px > 0 && px < w && py > 0 && py < h, px, py);
/* ── ① 基线:不贴法线图 ───────────────────────────────────────────────── */
/* ① Baseline: no normal map */
ui_2d_present();
grab(SA, vp, px, py, rad);
/* ── ② 中性法线图 ⇒ 数学恒等 ⇒ 几乎不变(容差 2,浮点误差) ─────────────── */
/* ② Neutral normal map => the identity => barely changes (tolerance 2 for float error) */
r = ui_3d_obj_set_normal_map(o_quad, img_neutral);
ck("set_normal_map(neutral) rc", r == 0, r, 0);
ui_2d_present();
grab(SB, vp, px, py, rad);
d_neutral = max_delta(SA, SB);
printf("DELTA neutral=%d\n", d_neutral);
ck("neutral normal map is the identity", d_neutral <= 2, d_neutral, 0);
/* ── ③ 扰动法线图 ⇒ 同一批采样点的光照**必须**变 ─────────────────────── */
/* ③ Perturbed normal map => the lighting at the same samples **must** change */
r = ui_3d_obj_set_normal_map(o_quad, img_tilt);
ck("set_normal_map(tilted) rc", r == 0, r, 0);
ui_2d_present();
grab(SB, vp, px, py, rad);
d_tilt = max_delta(SA, SB);
printf("DELTA tilted=%d\n", d_tilt);
/* ⚠ 这一条才是能响的那条:把 set_normal_map 实现成"什么都不做"⇒ delta 恒 0 ⇒ 红。
* ⚠ This is the loud one: a no-op set_normal_map leaves delta at 0 and turns this red. */
ck("tilted normal map changes the lighting", d_tilt > 12, d_tilt, 0);
/* ── ④ 从高度图推出来的那张(真正给"写实"用的)也必须有可见效果 ─────────── */
/* ④ The height-field-derived map (the one realism actually uses) must be visible too */
ui_3d_obj_set_normal_map(o_quad, img_rock);
ui_2d_present();
grab(SB, vp, px, py, rad);
printf("DELTA rock=%d\n", max_delta(SA, SB));
ck("derived rock normal map changes the lighting", max_delta(SA, SB) > 8, max_delta(SA, SB), 0);
/* ── ⑤ 反证:取消法线图 ⇒ 逐位回到基线 ────────────────────────────────── */
/* ⑤ Reverse: detaching the normal map returns bit-identically to the baseline */
r = ui_3d_obj_set_normal_map(o_quad, 0);
ck("set_normal_map(0) rc", r == 0, r, 0);
ui_2d_present();
grab(SB, vp, px, py, rad);
ck("unset -> identical to baseline", all_identical(SA, SB), max_delta(SA, SB), 0);
/* ── ⑥ 见证判据:uv 全 0 的网格上,法线图**确实不起作用** ──────────────── */
/* ⑥ Witness: on an all-zero-uv mesh the normal map **really does nothing** */
/* ⚠ 这是**已知限制**(TBN 靠屏幕空间导数,uv 退化时 det = 0):判据把现状钉住,实现改了它会立刻变红。
* ⚠ This is a **known limitation** (the TBN comes from screen derivatives and det = 0 when uv degenerates). The criterion freezes today's behaviour: when it is handled this line turns red. */
gen_quad_uv0(12, 3.2f);
m_uv0 = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
o_uv0 = ui_3d_obj_create(vp, m_uv0);
ui_3d_obj_set_color(o_uv0, argb(200, 200, 205));
if (o_uv0 > 0 && m_uv0 > 0) {
ui_3d_obj_set_visible(o_quad, 0);
ui_2d_present();
grab(SA, vp, px, py, rad);
ui_3d_obj_set_normal_map(o_uv0, img_tilt);
ui_2d_present();
grab(SB, vp, px, py, rad);
printf("DELTA uv0=%d\n", max_delta(SA, SB));
ck("all-zero uv: normal map has no effect (known limit)",
all_identical(SA, SB), max_delta(SA, SB), 0);
ui_3d_obj_set_visible(o_quad, 1);
}
/* ── ⑦ 错误路径 ──────────────────────────────────────────────────────── */
/* ⑦ Error paths == */
r = ui_3d_obj_set_normal_map(9999, img_tilt);
ck("bad object rejected", r < 0, r, 0);
r = ui_3d_obj_set_normal_map(o_quad, 999999);
ck("bad image rejected", r < 0, r, 0);
/* 收尾:把岩石法线图装回去,供 --frames 出图。
* Final: put the rock normal map back so the exported frame shows the feature. */
ui_3d_obj_set_normal_map(o_quad, img_rock);
ui_2d_present();
sprintf(RLINE, "NORMALMAP_DEMO %s (fails=%d)\n", g_fails == 0 ? "PASS" : "FAIL", g_fails);
rpt_line(RLINE);
printf("NORMALMAP_DEMO %s (fails=%d)\n", g_fails == 0 ? "PASS" : "FAIL", g_fails);
if (g_rpt >= 0) fclose(g_rpt);
return g_fails;
}