作为开发者在HarmonyOS上没有现成的三维渲染库可用,所以我花了一个国庆假期的时间,用原生管线从零搭了一套空间几何体可视化的Demo。如果你手头正好有类似需求——不管是做数学教学工具、工程图纸预览,还是室内漫游这类偏基础的3D场景,这篇文章基本可以当做一份参考模板来“抄作业”。我会把技术选型的思路、几何体数据的生成算法、渲染管线的搭建、手势交互的实现,以及我踩过的几个坑,一条条讲清楚。
先说需求场景。我要做的功能很简单:在HarmonyOS平板上展示球体、圆柱、圆锥、圆台、棱柱这些空间几何体,支持手指拖拽旋转、双指缩放、双击还原视角。看起来是个很小的功能,真正动手才发现需要整条原生渲染链路:XComponent承载渲染视图,EGL对接OpenGL ES,自己生成顶点数据、写Shader、管理MV矩阵。整个过程做完之后,我的一个明显感受是:基础几何体可视化虽然是最入门的三维渲染应用,但涉及的知识点非常全,做一遍相当于把图形学基础课在ARM设备上重新复习了一遍。
1. 为什么在HarmonyOS上做3D可视化,我选择不依赖WebView
先说一个反直觉的结论:在HarmonyOS上展示三维几何体,最快的路线往往不是最靠谱的路线。很多人第一时间会想到WebView加载Three.js。这个方案确实开发速度快,Three.js封装了丰富的几何体API,几行代码就能画出五十种几何体。但在实际项目里,我对把核心3D渲染逻辑放在WebView里这件事非常犹豫。
原因有三点。第一,资源占用问题。WebView加载Three.js需要几MB的JavaScript引擎开销,同时在滚动列表之类的场景中,WebView的内存回收策略也很难控制。第二,系统集成体验割裂。HarmonyOS应用如果核心交互页面是一个WebView页面,深色模式适配、字体缩放、横竖屏切换、多任务卡片这些系统能力都要额外做兼容。第三,也是最实际的问题:如果后续要在这个基础上做离屏渲染、动态纹理、自定义Shader,WebView方案很快会被卡住。
所以我的选型很直接:XComponent + EGL + OpenGL ES。XComponent是HarmonyOS提供的原生渲染承载组件,它本质上是一块直接绑定到系统Native窗口的Surface,宿主通过它的回调拿到surface的句柄,在Native侧建立起EGL环境后,所有OpenGL ES的绘制结果都会直接呈现到这上面。这条路没有额外运行时,渲染效率和硬件GPU是直通的。
另外也有人问我,为什么不直接上Vulkan。我的判断是:对于空间几何体可视化这种场景,OpenGL ES 3.0无论在设备兼容性还是开发效率上都更合适。Vulkan的优势在于多线程渲染和底层资源控制,但对应的是更复杂的同步机制和队列管理。几何体可视化的瓶颈根本不在渲染管线的效率和驱动开销上,而更可能出现在顶点数据的细分数和手势交互的响应上,这套需求用OpenGL ES完全能顶住。
还有一个选型上的细节:矩阵运算库。HarmonyOS原生侧没有像glm那样现成的矩阵库,但如果引入cppglm又涉及到本地编译链的版本问题。我最终的选择是自己维护了一个最小的mat4工具类,实现了lookAt、perspective、rotate、multiply这四个函数。对于当前的需求,这套迷你矩阵库已经足够,代码量只有几百行,还避免了第三方库在不同工具链版本下的ABI兼容问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 几何体数据生成:顶点、法线和索引的正确组织方式
在开始写渲染管线之前,首先要解决几何体数据从哪来的问题。OpenGL ES本身不认识“球体”或“圆锥”这种概念,它只认两种原语:点、线、三角形。所以任何几何体都必须被拆解成一组顶点坐标、一组法线向量、一组索引,然后打包上传到GPU显存。
2.1 数据布局:CPU侧的几何体描述
我定义了一个统一的Geometry结构,里面包含五段数据:
cpp复制struct Geometry {
std::vector<float> positions; // 顶点坐标,每3个float一组
std::vector<float> normals; // 法线,每3个float一组
std::vector<float> texcoords; // 纹理坐标,每2个float一组
std::vector<unsigned int> indices; // 三角形索引
int vertexCount = 0; // 顶点数
int indexCount = 0; // 索引数
};
positions段规定了每个顶点在世界空间里的位置,normals段是每个顶点对应的法线方向(光照计算的关键),indices段描述三角形如何连接。纹理坐标在目前这个场景里用处不大,但我仍然预留了,因为后续很可能做坐标轴纹理或者渐变背景。
很多第一次写渲染程序的人会忽略一个关键点:为什么三角形索引是必要的?如果不使用索引,每画一个三角形要提交3个顶点,一个立方体有12个三角形,就要提交36个顶点。但立方体实际上只有8个不同的顶点位置,每个顶点被多个三角形共享。索引缓冲能把顶点数从36个降到8个,在球体这种大规模网格上,这个节省是数量级的。
2.2 立方体:最简单的案例
立方体虽然简单,但它是理解顶点数据如何组织的最佳入门案例。先贴出核心实现:
cpp复制void generateCube(Geometry& geo) {
// 8个顶点位置
float vertices[8][3] = {
{-0.5f, -0.5f, -0.5f}, {0.5f, -0.5f, -0.5f},
{0.5f, 0.5f, -0.5f}, {-0.5f, 0.5f, -0.5f},
{-0.5f, -0.5f, 0.5f}, {0.5f, -0.5f, 0.5f},
{0.5f, 0.5f, 0.5f}, {-0.5f, 0.5f, 0.5f}
};
// 每个面对应4个顶点,两个三角形拼成1个面,法线按面方向指定
unsigned int faceIndices[6][6] = {
{0, 1, 2, 0, 2, 3}, // 前面
{5, 4, 7, 5, 7, 6}, // 后面
{1, 5, 6, 1, 6, 2}, // 右面
{4, 0, 3, 4, 3, 7}, // 左面
{3, 2, 6, 3, 6, 7}, // 顶面
{4, 5, 1, 4, 1, 0} // 底面
};
// 注意:为了让顶点着色器里好计算法线,这里按面复制顶点位置
// 每个面4个顶点,共24个顶点
for (int face = 0; face < 6; face++) {
float nx = 0, ny = 0, nz = 0;
// 根据面和右手定则计算面法线
if (face == 0) nz = -1; // 前面
else if (face == 1) nz = 1; // 后面
else if (face == 2) nx = 1; // 右面
else if (face == 3) nx = -1; // 左面
else if (face == 4) ny = 1; // 顶面
else if (face == 5) ny = -1; // 底面
for (int i = 0; i < 4; i++) {
int srcIndex = faceIndices[face][i];
geo.positions.insert(geo.positions
