第十课:渲染与可视化——OpenGL 实战,把分析结果”画”出来
为什么排第十?
第九课的报告把数据翻译成了文字,但文字有局限——

“薄壁区域位于 (12.3, 5.0, 8.1) 至 (18.7, 9.2, 11.5)”

用户看到这行字,脑子里能浮现出那个位置吗?大概率不能。但如果模型上那块区域闪着红光,用户一秒就懂了。

可视化 = 把”读”变成”看”。人类视觉带宽是文字阅读的 10 万倍——一张图胜过一千行报告。

用你的话说:”技术是切入点,但用户最终感知的是视觉”(理念 1.2)——你的曲率算得再精确,用户看不到就等于没有。

费曼学习法 Step 1:用大白话解释它是什么
想象你做了一份体检报告:

文字版:”左肺下叶有 3mm 结节,位于第5肋间隙内侧”——你得自己想象位置
CT 影像版:一张 3D 重建图,结节用红色高亮标注——一眼就看到了
在 Huhb3D 中:

文字报告 = 第九课的输出
3D 可视化 = 本课的任务——把薄壁画成红色、悬垂面画成橙色、孔洞画成紫色
用户可以旋转、缩放、点击区域查看详情
费曼学习法 Step 2:逐个拆解核心知识点
📦 A. 渲染管线——从数据到像素的旅程
A1. 一帧画面的诞生

┌──────────────────────────────────────────────────────────────────┐
│ 渲染管线 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 顶点数据 │──→│ 顶点着色器│──→│ 光栅化 │──→│ 片段着色器│ │
│ │ (VBO) │ │ (变换+投影)│ │ (三角形→像素)│ │ (上色) │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ ↑ │ │
│ │ ┌────▼────┐ │
│ ┌────┴────┐ │ 帧缓冲 │ │
│ │ 索引数据 │ │ (屏幕) │ │
│ │ (EBO) │ └─────────┘ │
│ └─────────┘ │
└──────────────────────────────────────────────────────────────────┘
A2. 每一步在做什么

阶段 输入 做什么 输出
顶点数据 三角形顶点坐标 + 颜色 + 法线 准备 GPU 可读的内存 VBO + EBO
顶点着色器 每个顶点 MVP 变换(模型→世界→裁剪→屏幕) 屏幕空间坐标
光栅化 变换后的三角形 把三角形内部填满像素 片段
片段着色器 每个片段 计算颜色(光照 + 材质 + 高亮) 像素颜色
A3. ★ Huhb3D 的特殊需求:同一模型,多种上色模式

模式1: 法线模式 → 每个顶点用法线方向映射RGB
模式2: 曲率模式 → 每个顶点用曲率值映射热力图
模式3: 聚类模式 → 每个聚类随机颜色
模式4: 特征模式 → 按第九课的严重度上色(红/橙/黄/绿)
模式5: 壁厚模式 → 壁厚值映射热力图
关键洞察: 模型不变,颜色变了就是另一个视图。所以数据结构要分离:拓扑(不变)和颜色(可变)分开存储。

📦 B. VBO 与 EBO——GPU 的”喂饭方式”
B1. 为什么需要 VBO + EBO?

一个三角形 = 3 个顶点。但相邻三角形共享顶点(第三课的合并后网格),如果每个三角形单独存 3 个顶点,内存浪费巨大。

不合并:6 个三角形 × 3 顶点 = 18 份顶点数据
合并后:8 个独立顶点(共享),用索引引用

共享顶点的情况:
v0──v1──v2
│ ╲ │ ╲ │
v3──v4──v5
│ ╲ │ ╲ │
v6──v7

三角形用索引表示:
tri0: [v0, v3, v4] tri1: [v0, v4, v1]
tri2: [v1, v4, v5] tri3: [v1, v5, v2]

B2. VBO(Vertex Buffer Object)——顶点数据的 GPU 内存

struct Vertex {
float position[3]; // 位置 xyz
float normal[3]; // 法线 xyz
float color[4]; // 颜色 rgba
}; // 每个顶点 40 字节

// 创建 VBO
GLuint vbo;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER,
vertices.size() * sizeof(Vertex),
vertices.data(),
GL_STATIC_DRAW); // ★ 数据不变用 STATIC
B3. EBO(Element Buffer Object)——索引的 GPU 内存

// 创建 EBO
GLuint ebo;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER,
indices.size() * sizeof(uint32_t),
indices.data(),
GL_STATIC_DRAW);
B4. ★ GL_STATIC_DRAW vs GL_DYNAMIC_DRAW

标志 含义 什么时候用
GL_STATIC_DRAW 数据一次上传,不变 模型的顶点坐标、法线、索引
GL_DYNAMIC_DRAW 数据频繁更新 颜色——切换渲染模式时颜色要重新上传
关键设计: 把 VBO 拆成两部分——

// 不变部分:位置 + 法线(STATIC)
GLuint vbo_geometry;
// position[3] + normal[3] = 24 bytes/vertex

// 可变部分:颜色(DYNAMIC)
GLuint vbo_color;
// color[4] = 16 bytes/vertex

// 切换渲染模式时,只更新 vbo_color,不动 vbo_geometry
这样切换渲染模式只需上传颜色数据(16 bytes/vertex),不用重新上传几何数据(24 bytes/vertex)。只动该动的,不动不该动的。

AI坑点: AI 把位置、法线、颜色打成一个 struct,一个 VBO。切换渲染模式时整个 VBO 都要重新上传——百万顶点的模型,每切一次模式就要传 40MB 数据到 GPU,肉眼可见的卡顿。

📦 C. 顶点着色器——3D 到 2D 的数学
C1. MVP 变换——模型怎么出现在屏幕上

物体空间 ──[Model矩阵]──→ 世界空间 ──[View矩阵]──→ 相机空间 ──[Projection矩阵]──→ 裁剪空间
矩阵 做什么 谁控制
Model 模型在世界中的位置/旋转/缩放 用户拖拽
View 相机在哪、看哪 用户视角
Projection 透视/正交,近大远小 投影设置
C2. 顶点着色器代码

#version 330 core

layout(location = 0) in vec3 aPosition; // 位置
layout(location = 1) in vec3 aNormal; // 法线
layout(location = 2) in vec4 aColor; // 颜色

uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;

out vec3 vNormal; // 传给片段着色器
out vec4 vColor;

void main() {
gl_Position = uProjection * uView * uModel * vec4(aPosition, 1.0);
vNormal = mat3(transpose(inverse(uModel))) * aNormal; // ★ 法线变换
vColor = aColor;
}
C3. ★ 法线变换为什么不是 mat3(uModel) * aNormal?

只有均匀缩放(x/y/z 缩放相同)时,法线才能直接乘 Model 矩阵的上 3×3。如果有非均匀缩放(比如 x 方向拉长 2 倍),直接乘会让法线偏转,光照计算错误。

正确公式:mat3(transpose(inverse(uModel))) * normal

这是图形学的经典坑——数学上叫法线矩阵 = 模型矩阵逆转置的上 3×3。

AI坑点: AI 默认写 mat3(uModel) * aNormal——在均匀缩放时碰巧正确,非均匀缩放时光照完全错误,模型表面出现诡异的明暗条纹。

📦 D. 片段着色器——上色的艺术
D1. 基础光照 + 颜色

#version 330 core

in vec3 vNormal;
in vec4 vColor;

uniform vec3 uLightDir; // 光照方向
uniform float uAmbient; // 环境光强度
uniform bool uEnableLighting; // 是否启用光照
uniform bool uEnableHighlight; // 是否启用高亮(选中区域)
uniform vec4 uHighlightColor; // 高亮颜色

out vec4 FragColor;

void main() {
vec4 baseColor = vColor;

if (uEnableLighting) {
    // ★ Lambert 漫反射
    vec3 N = normalize(vNormal);
    float diff = max(dot(N, normalize(uLightDir)), 0.0);
    float lighting = uAmbient + (1.0 - uAmbient) * diff;
    baseColor.rgb *= lighting;
}

// ★ 选中区域闪烁效果
if (uEnableHighlight) {
    float pulse = 0.5 + 0.5 * sin(uTime * 4.0);  // 脉冲动画
    baseColor = mix(baseColor, uHighlightColor, pulse * 0.6);
}

FragColor = baseColor;

}
D2. ★ 为什么用 Lambert 不用 Phong?

光照模型 效果 计算量 适合场景
Lambert 柔和漫反射 1 个 dot 工程可视化——看形状,不看质感
Phong 高光 + 漫反射 + 环境光 3 次计算 游戏渲染——看质感
PBR 物理真实 5+ 次计算 电影级渲染
Huhb3D 的目的是让用户看清模型的问题区域,不是做漂亮的渲染。Lambert 够用,高光反而会干扰颜色的可读性——一块红色薄壁区域上面飘着白色高光,用户分不清”这是红色还是高光”。

又见”技术够用就可以”(理念 1.3)。

📦 E. 颜色映射——从数值到色彩
E1. 曲率热力图

// 把曲率值映射到热力图颜色
vec3 curvature_colormap(float value, float min_val, float max_val) {
float t = (value - min_val) / (max_val - min_val); // 归一化到 [0, 1]
t = clamp(t, 0.0f, 1.0f);

// ★ 5 段热力图:蓝 → 青 → 绿 → 黄 → 红
vec3 color;
if (t < 0.25f) {
    color = lerp(vec3(0, 0, 1), vec3(0, 1, 1), t * 4.0f);       // 蓝→青
} else if (t < 0.5f) {
    color = lerp(vec3(0, 1, 1), vec3(0, 1, 0), (t - 0.25f) * 4.0f); // 青→绿
} else if (t < 0.75f) {
    color = lerp(vec3(0, 1, 0), vec3(1, 1, 0), (t - 0.5f) * 4.0f);  // 绿→黄
} else {
    color = lerp(vec3(1, 1, 0), vec3(1, 0, 0), (t - 0.75f) * 4.0f); // 黄→红
}

return color;

}
E2. 严重度颜色映射

vec3 severity_color(Severity sev) {
switch (sev) {
case Severity::FATAL: return vec3(0.9f, 0.0f, 0.0f); // 红
case Severity::CRITICAL: return vec3(1.0f, 0.5f, 0.0f); // 橙
case Severity::WARNING: return vec3(1.0f, 0.9f, 0.0f); // 黄
case Severity::INFO: return vec3(0.3f, 0.8f, 0.3f); // 绿
}
}
E3. ★ 颜色不是”好看”,是”信息”

设计选择 原因
红色 = FATAL 人类本能对红色警觉
橙色 = CRITICAL 暖色但不是红色 = “严重但不是绝境”
黄色 = WARNING 注意但不必紧张
绿色 = INFO 安全
蓝→红热力图 连续值的标准可视化——蓝=低、红=高,全球通用
AI坑点: AI 选颜色看”好看”不看”信息”——用紫色表示 FATAL、青色表示 WARNING。技术上没毛病,但违反了人类的颜色直觉(红=危险、绿=安全),用户需要额外学习才能理解。

📦 F. 交互——旋转、缩放、点选
F1. Arcball 旋转——3D 旋转的正确方式

// ★ 核心思想:把2D鼠标拖拽映射到3D球面上的旋转

vec3 get_arcball_vector(vec2 mouse_pos, vec2 screen_center, float radius) {
vec3 P = vec3(
(mouse_pos.x - screen_center.x) / radius,
(mouse_pos.y - screen_center.y) / radius,
0.0f
);

float P_sq = P.x * P.x + P.y * P.y;

if (P_sq <= 1.0f) {
    P.z = sqrtf(1.0f - P_sq);  // 球面上
} else {
    P = normalize(P);           // 球面外,投影到球面
    P.z = 0.0f;
}

return P;

}

// 鼠标从 P1 拖到 P2 → 旋转四元数
quat rotation = quat_from_two_vectors(
get_arcball_vector(mouse_prev, center, radius),
get_arcball_vector(mouse_curr, center, radius)
);
F2. ★ 为什么不用欧拉角旋转?

旋转方式 问题
欧拉角 (rx, ry, rz) 万向节锁——旋转到某些角度会丢失一个自由度
四元数 无万向节锁,插值平滑
欧拉角在 90° 附近会让模型”抽搐”——这是万向节锁的典型表现。四元数彻底解决。

AI坑点: AI 默认用 glRotatef(angleX, 1,0,0); glRotatef(angleY, 0,1,0);——看起来简单,但拖拽到特定角度模型会”跳”,用户体验极差。

F3. 点选——点击三角形的射线检测

// 鼠标点击 → 屏幕坐标 → 3D射线
Ray screen_to_ray(vec2 mouse_pos, mat4 view, mat4 projection,
vec2 screen_size) {
// 屏幕坐标 → NDC
vec2 ndc = vec2(
(2.0f * mouse_pos.x) / screen_size.x - 1.0f,
1.0f - (2.0f * mouse_pos.y) / screen_size.y
);

// NDC → 世界空间射线
mat4 inv_vp = inverse(projection * view);
vec4 near_point = inv_vp * vec4(ndc, -1.0f, 1.0f);
vec4 far_point  = inv_vp * vec4(ndc,  1.0f, 1.0f);

vec3 origin = vec3(near_point) / near_point.w;
vec3 dir = normalize(vec3(far_point) / far_point.w - origin);

return {origin, dir};

}

// 射线与三角形求交(Möller-Trumbore 算法)
bool ray_triangle_intersect(Ray ray, vec3 v0, vec3 v1, vec3 v2,
float* t_out) {
vec3 e1 = v1 - v0;
vec3 e2 = v2 - v0;
vec3 h = cross(ray.dir, e2);
float a = dot(e1, h);

if (fabs(a) < 1e-8f) return false;  // 射线平行于三角形

float f = 1.0f / a;
vec3 s = ray.origin - v0;
float u = f * dot(s, h);
if (u < 0.0f || u > 1.0f) return false;

vec3 q = cross(s, e1);
float v = f * dot(ray.dir, q);
if (v < 0.0f || u + v > 1.0f) return false;

float t = f * dot(e2, q);
if (t > 1e-8f) {
    *t_out = t;
    return true;
}

return false;

}
F4. ★ 点选加速——BVH 又来了

百万三角形逐个做射线求交?O(N) 太慢。用第四课的 BVH 加速:

// BVH 加速的射线求交——O(log N)
int find_hit_triangle(Ray ray, const BVHNode* bvh,
const vector& triangles) {
int hit_tri = -1;
float closest_t = FLT_MAX;

// BFS 遍历 BVH
vector<int> stack;
stack.push_back(0);  // 根节点

while (!stack.empty()) {
    int node_idx = stack.back();
    stack.pop_back();
    const BVHNode& node = bvh[node_idx];
    
    // 射线不与 AABB 相交 → 剪枝
    if (!ray_aabb_intersect(ray, node.aabb)) continue;
    
    if (node.is_leaf()) {
        // 叶子节点:测试三角形
        for (int i = node.start; i < node.end; i++) {
            float t;
            if (ray_triangle_intersect(ray, 
                    triangles[i].v0, triangles[i].v1, triangles[i].v2, &t)) {
                if (t < closest_t) {
                    closest_t = t;
                    hit_tri = i;
                }
            }
        }
    } else {
        // 内部节点:压入子节点
        stack.push_back(node.left);
        stack.push_back(node.right);
    }
}

return hit_tri;

}
BVH 把射线求交从 O(N) 降到 O(log N)——第四课造的轮子,第十课用上了。架构设计的复利。

📦 G. 选中区域高亮——交互的闭环
G1. 点选→高亮→显示详情

用户点击模型表面


射线求交 → 找到 hit_triangle


查第七课的聚类表 → triangle_cluster[hit_triangle] → cluster_id


高亮该聚类的所有三角形(着色器 pulse 动画)


右侧面板显示该聚类的详情(第九课的 IssueDescription)
G2. 实现:选中状态的 GPU 传递

// CPU 端:标记选中的聚类
int selected_cluster_id = -1; // -1 = 未选中

void on_mouse_click(vec2 mouse_pos) {
Ray ray = screen_to_ray(mouse_pos, view, projection, screen_size);
int hit_tri = find_hit_triangle(ray, bvh, triangles);

if (hit_tri >= 0) {
    selected_cluster_id = triangle_cluster[hit_tri];
    // 更新颜色缓冲——选中的聚类用高亮颜色
    update_color_buffer(selected_cluster_id);
} else {
    selected_cluster_id = -1;
    update_color_buffer(-1);  // 取消高亮
}

}

// 更新颜色:只改被选中聚类的颜色
void update_color_buffer(int selected_id) {
vector colors(vertex_count);

for (int i = 0; i < vertex_count; i++) {
    int tri_idx = vertex_to_triangle[i];  // 顶点→三角形映射
    int cluster_id = triangle_cluster[tri_idx];
    int geo_type = cluster_geometry_type[cluster_id];
    Severity sev = cluster_severity[cluster_id];
    
    vec3 base_color = severity_color(sev);  // 默认用严重度着色
    
    if (cluster_id == selected_id) {
        // ★ 选中区域:用亮色覆盖
        colors[i] = vec4(1.0f, 1.0f, 0.5f, 1.0f);  // 亮黄色
    } else {
        colors[i] = vec4(base_color, 1.0f);
    }
}

// ★ 只上传颜色 VBO,不上传几何 VBO
glBindBuffer(GL_ARRAY_BUFFER, vbo_color);
glBufferSubData(GL_ARRAY_BUFFER, 0,
                colors.size() * sizeof(vec4),
                colors.data());

}
📦 H. 渲染优化——帧率保卫战
H1. 性能预算

模型规模 目标帧率 每帧时间
< 10万三角形 60 FPS < 16.7ms
10-100万三角形 30 FPS < 33.3ms

100万三角形 15 FPS < 66.7ms
H2. 面剔除——立刻省一半

glEnable(GL_CULL_FACE);
glCullFace(GL_BACK); // 剔除背面
glFrontFace(GL_CCW); // 逆时针为正面(第三课的统一绕向)
3D模型是封闭表面,从外面看永远只看到正面。背面的三角形直接不画——三角形数量减半,帧率翻倍。

前提: 第三课统一了三角形绕向(CCW),所以第十课才能安全地剔除背面。前课的设计是后课的基石。

H3. ★ 视锥剔除——看不见的不画

// 只渲染 BVH 中与视锥相交的节点
void render_bvh_culled(const BVHNode* bvh, const Frustum& frustum) {
vector stack;
stack.push_back(0);

while (!stack.empty()) {
    int node_idx = stack.back();
    stack.pop_back();
    const BVHNode& node = bvh[node_idx];
    
    // ★ BVH 节点不在视锥内 → 整棵子树都不画
    if (!frustum.intersects(node.aabb)) continue;
    
    if (node.is_leaf()) {
        glDrawElements(GL_TRIANGLES, 
                      node.tri_count * 3,
                      GL_UNSIGNED_INT,
                      (void*)(node.start * 3 * sizeof(uint32_t)));
    } else {
        stack.push_back(node.left);
        stack.push_back(node.right);
    }
}

}
用户缩放到只看模型的一个角——BVH 帮你跳过 80% 的三角形。又见第四课的复利。

H4. ★ 避免每帧 CPU→GPU 数据重传

// ❌ AI 的写法:每帧重新上传全部数据
void render() {
glBufferData(GL_ARRAY_BUFFER, size, data, GL_STATIC_DRAW); // 每帧!
glDrawArrays(…);
}

// ✅ 正确做法:只在数据变化时上传
void init() {
glBufferData(GL_ARRAY_BUFFER, size, data, GL_STATIC_DRAW);
}

void render() {
glDrawArrays(…); // GPU 侧数据还在,直接画
}

void on_color_changed() {
glBufferSubData(GL_ARRAY_BUFFER, offset, new_color_size, new_colors);
}
glBufferData 是分配+上传,glBufferSubData 是只上传变更部分。区别在于前者让 GPU 重新分配内存(触发隐式同步),后者原地更新。

AI坑点: AI 在渲染循环里调用 glBufferData——每帧重新分配 GPU 内存。50万顶点的模型,每帧上传 20MB,GPU 驱动忙着分配/释放,帧率直接腰斩。

费曼学习法 Step 3:完整流水线
┌──────────────────────────────────────────────────────────────────┐
│ 初始化阶段 │
│ │
│ STL解析(第8课) → 网格构建(第3课) → 曲率计算(第6课) │
│ → 聚类分类(第7课) → 报告生成(第9课) │
│ │
│ 生成数据: │
│ ├── vertices[] (位置+法线) → vbo_geometry (STATIC) │
│ ├── indices[] → ebo (STATIC) │
│ ├── colors[] (随模式变) → vbo_color (DYNAMIC) │
│ ├── BVH (第4课) → 视锥剔除 + 射线检测 │
│ └── cluster_map[] (第7课) → 点选→聚类→高亮 │
└──────────────────────┬───────────────────────────────────────────┘


┌──────────────────────────────────────────────────────────────────┐
│ 每帧渲染循环 │
│ │
│ 1. 处理输入 │
│ ├── 鼠标拖拽 → Arcball旋转 → 更新 View 矩阵 │
│ ├── 滚轮缩放 → 更新 Projection 矩阵 │
│ └── 鼠标点击 → 射线检测 → 选中聚类 → 更新颜色缓冲 │
│ │
│ 2. 更新着色器 Uniform │
│ ├── uModel, uView, uProjection │
│ ├── uLightDir │
│ ├── uEnableHighlight + uHighlightColor + uTime │
│ │
│ 3. 视锥剔除(BVH 辅助) │
│ │
│ 4. 绘制 │
│ ├── 绑定 VBO + EBO │
│ ├── 背面剔除 │
│ └── glDrawElements │
│ │
│ 5. 绘制 UI 叠加层 │
│ ├── 问题列表(与 3D 视口联动) │
│ ├── 模式切换按钮 │
│ └── 选中区域的详情面板 │
└──────────────────────────────────────────────────────────────────┘
费曼学习法 Step 4:识别你的知识缺口
# 自检问题 如果你答不出
1 为什么把几何 VBO 和颜色 VBO 分开? STATIC vs DYNAMIC——只上传变化的部分,不重传不变的
2 法线变换为什么不能直接乘 Model 矩阵? 非均匀缩放会让法线偏转——需要用逆转置
3 为什么用 Lambert 不用 Phong 光照? 工程可视化看形状不看质感,高光会干扰颜色可读性
4 Arcball 旋转用四元数不用欧拉角,为什么? 万向节锁——欧拉角在 90° 附近丢失自由度,拖拽会”抽搐”
5 射线检测为什么要 BVH 加速? 百万三角形逐个求交 O(N) 太慢,BVH 降到 O(log N)
6 面剔除的前提是什么?为什么第三课要统一绕向? 面剔除依赖一致的正反面判定——绕向不统一剔除结果错误
7 glBufferData 和 glBufferSubData 的区别? 前者重新分配+上传,后者原地更新——前者在渲染循环里会让帧率腰斩
费曼学习法 Step 5:用你的话复述
“3D 可视化就是把分析结果’画’出来。模型数据上传到 GPU 的 VBO/EBO,顶点着色器做 MVP 变换把 3D 投射到 2D,片段着色器用 Lambert 光照上色。

关键设计是分离不变的几何数据和可变的颜色数据——切换渲染模式只上传颜色,不重传几何。颜色不是’好看’是’信息’——红=FATAL、绿=INFO,用的是人类直觉,不是审美。

交互靠射线检测——鼠标点击转成 3D 射线,BVH 加速找交点,映射到聚类,高亮整个问题区域。旋转用四元数 Arcball,不用欧拉角,避免万向节锁。

性能三板斧:面剔除(前提是第三课统一了绕向)、视锥剔除(第四课的 BVH 又派上用场)、避免每帧重传数据。每一步优化都依赖前课的设计——架构的复利。”

📌 本课与你的 AI 时代理念的映射
你的理念 本课体现
1.1 当出题人 颜色映射规则(红=致命)、渲染模式设计、交互流程——你定义了”怎么看”
1.2 技术是切入点 VBO/EBO/着色器是技术,”为什么用红绿黄不是紫青粉”是设计——技术为认知服务
1.4 架构能力 几何/颜色 VBO 分离、BVH 复用、点选→聚类→高亮的交互链路——每一层为谁服务、怎么连接,是架构
1.5 AI代码有坑 AI 用欧拉角(万向节锁)、VBO 不分离(每帧重传)、法线直接乘 Model(非均匀缩放错误)、渲染循环调 glBufferData(帧率腰斩)
1.6 软实力 可视化本身就是软实力——把”壁厚0.3mm”翻译成”那块闪红光的区域”,用户秒懂
1.3 技术够用就可以 Lambert 不用 Phong、支撑估算不精确到0.01g——够用就行,过度设计浪费算力和时间