Preface
- lib3mf integration series (1): compilation and sample testing
- lib3mf integration series (2): Flutter FFI plug-in creation and Android integration
- lib3mf integration series (3): Parsing 3MF file information in Flutter
- lib3mf integration series (4): Flutter Texture and C++ OpenGL cross-terminal rendering architecture
- lib3mf integrated series (5): C++ extraction of 3MF data and OpenGL rendering practice
In the previous article, we successfully opened up the underlying architecture link from Flutter to provide Texture, Android manages the EGL thread, and JNI triggers C++ SceneRenderer.
Before officially dismantling the 3MF geometry data package, we need to answer a question first: Why should the rendering logic be completely moved to the C++ layer instead of throwing the data to Kotlin or even Flutter through JNI for rendering?
This involves very critical performance considerations.
Why choose to render at the C++ level instead of the Kotlin level?
-
Avoid the copy disaster of massive rendering data: 3MF models (such as complex industrial parts or detailed statues) often have hundreds of thousands or even millions of vertices.
lib3mfThe library itself is written in C++. If we plan to use theGLES30.*API for rendering in Kotlin (Java), then we have to keep copying and serializing these astronomical numbers of C++ vertex structures (arrays) to Java’sFloatBufferorIntBufferviaJNI. This kind of off-heap/in-heap memory transfer of large amounts of data will cause fatal performance bottlenecks and OOM. On the contrary, by rendering directly through OpenGL in C++, we only need to obtain the native pointer oflib3mf, and then use VBO (Vertex Buffer Object) in C++ to directly transfer it to the GPU memory, achieving “zero copy” in the physical sense. -
Excellent cross-platform performance: Whether it is later expanded to iOS or migrated to the desktop, if the graphics rendering code (Shader, model analysis, MVP matrix calculation) is solidified in C++ (such as the current
scene_renderer.cpp), we only need to switch to a different graphics pipeline interface, and there is no need to rewrite the rendering core in Swift or C#.
After clarifying this core idea, let’s dig deeper into the core model_loader.cpp and scene_renderer.cpp codes!
3MF file format sniffing
Before diving headfirst into the geometry data, we need to confirm that the file we’re getting is indeed a legitimate 3MF. This step may seem simple, but behind it lies an interesting point about the nature of the 3MF format.
According to the definition of 3MF Core Specification, the 3MF file is essentially a ZIP compressed package - it packages all XML description files, textures resources, etc. in a standard ZIP container. Therefore, to determine if a binary stream is 3MF, we only need to check the magic number of the ZIP format:
ModelFormat ModelLoader::DetectFormat(const std::vector<uint8_t> &buffer) {
if (buffer.size() < 4)
return ModelFormat::Unknown;
// 3MF 文件是一个 ZIP 包,前 4 个字节是固定的 PK\x03\x04
if (buffer[0] == 0x50 && buffer[1] == 0x4B &&
buffer[2] == 0x03 && buffer[3] == 0x04) {
return ModelFormat::ThreeMF;
}
// 尝试识别 STL ASCII 格式 (以 "solid" 开头)
if (buffer.size() >= 5) {
std::string header(buffer.begin(), buffer.begin() + 5);
if (header == "solid") {
return ModelFormat::STL;
}
}
// 兜底:超过 84 字节的未知文件,推测为 STL Binary
// (STL Binary 的前 80 字节是头信息,接下来 4 字节是三角形计数)
if (buffer.size() > 84) {
return ModelFormat::STL;
}
return ModelFormat::Unknown;
}
0x50 0x4B is the ASCII character PK, an abbreviation of Phil Katz, the inventor of the PKZIP format, almost all ZIP-based The container formats (.docx, .jar, .apk, .3mf) all share the same magic number header.
After validating the format, we load it in relaxed mode via lib3mf’s Reader:
PReader reader = model->QueryReader("3mf");
reader->SetStrictModeActive(false); // 宽松模式,容忍非致命的格式偏差
reader->ReadFromBuffer(buffer);
SetStrictModeActive(false) This line is very critical - in the real world, the 3MF files exported by many slicing software do not fully comply with the standard specifications (such as missing some optional XML namespace declarations). The relaxed mode allows our parser to tolerate such common deviations, greatly improving the compatibility coverage of the file.
Extraction and reconstruction of 3MF geometric data
We repack the extracted data into a compact layout for OpenGL. In this project, a one-dimensional float array stores each vertex in 11 floats: X, Y, Z position, R, G, B color, Nx, Ny, Nz normal, and U, V texture coordinates:
偏移 0-2: Position (x, y, z) — 世界坐标
偏移 3-5: Color (r, g, b) — 顶点色 (默认白色)
偏移 6-8: Normal (nx, ny, nz) — 法线 (待 ComputeNormals 计算)
偏移 9-10: TexCoord (u, v) — 纹理坐标 (预留,暂未使用)
Although texture maps have not been used in the current project, we have reserved the UV attribute slot in the memory layout in advance. This approach of taking future scalability into consideration at the design stage can avoid the embarrassment of having to readjust the entire VBO pipeline when adding texture support in the future.
// 预分配内存 (核心优化:防止 push_back 导致的频繁扩容搬家)
const uint32_t VERTEX_ARRAY_SIZE = 11;
model.vertices.resize(nVertexCount * VERTEX_ARRAY_SIZE);
model.indices.resize(nTriangleCount * 3);
The process of extracting coordinates is to obtain the mesh object (PMeshObject) through PObjectIterator and traverse the vertex filling:
float *vBuffer = model.vertices.data();
std::vector<sLib3MFPosition> vertexPositions(nVertexCount);
meshObject->GetVertices(vertexPositions);
for (Lib3MF_uint64 i = 0; i < nVertexCount; i++) {
sLib3MFPosition pos = vertexPositions[i];
uint32_t offset = i * VERTEX_ARRAY_SIZE;
// 1. 填充坐标
vBuffer[offset + 0] = pos.m_Coordinates[0]; // x
vBuffer[offset + 1] = pos.m_Coordinates[1]; // y
vBuffer[offset + 2] = pos.m_Coordinates[2]; // z
// 2. 填充颜色 (暂时设为白色: 1, 1, 1)
vBuffer[offset + 3] = 1.0f; // r
vBuffer[offset + 4] = 1.0f; // g
vBuffer[offset + 5] = 1.0f; // b
// 3. 法线先填默认朝上 (0, 0, 1),后面由 ComputeNormals 重算
vBuffer[offset + 6] = 0.0f; // nx
vBuffer[offset + 7] = 0.0f; // ny
vBuffer[offset + 8] = 1.0f; // nz
// 4. UV 预留为 (0, 0)
vBuffer[offset + 9] = 0.0f; // u
vBuffer[offset + 10] = 0.0f; // v
}
Then extract the triangular surface composed of three vertex indices through meshObject->GetTriangle(i) and save it to the indices array:
uint32_t *iBuffer = model.indices.data();
for (Lib3MF_uint64 i = 0; i < nTriangleCount; i++) {
sLib3MFTriangle tri = meshObject->GetTriangle(i);
uint32_t offset = i * 3;
iBuffer[offset + 0] = tri.m_Indices[0];
iBuffer[offset + 1] = tri.m_Indices[1];
iBuffer[offset + 2] = tri.m_Indices[2];
}
After the data extraction is completed, we will also calculate a Bounding Box for debugging:
glm::vec3 minBound(1e10f), maxBound(-1e10f);
for (size_t i = 0; i < nVertexCount; i++) {
glm::vec3 p(vBuffer[i * VERTEX_ARRAY_SIZE],
vBuffer[i * VERTEX_ARRAY_SIZE + 1],
vBuffer[i * VERTEX_ARRAY_SIZE + 2]);
minBound = glm::min(minBound, p);
maxBound = glm::max(maxBound, p);
}
LOGI("Bounding Box: Min(%f, %f, %f), Max(%f, %f, %f)",
minBound.x, minBound.y, minBound.z,
maxBound.x, maxBound.y, maxBound.z);
The bounding box information output by this step is extremely useful in development - if you load an industrial model and find that the picture is completely white or the camera seems to be misaligned, the first thing to do is to look at the output of the Bounding Box: 3MF coordinate units are usually mm, and a palm-sized cup may have (0, 0, 0) to (80, 80, 100) size range, and if your Camera’s far clipping plane is only set to 100.0f, it may be “stuck” on the edge of the model.
Core algorithm: area-weighted smoothed normals recalculation
3MF data often only provides pure geometric structures, and does not provide the vertex normals (Vertex Normal) required for smooth shading. Without calculated normals, the model will be extremely distorted or completely black/white under lighting.
There is a very valuable algorithmic trick here encapsulated in our ComputeNormals. The complete implementation is divided into three stages:
Step 1: Clear the normals components of all vertices
for (size_t i = 0; i < model.vertices.size() / 11; ++i) {
model.vertices[i * 11 + 6] = 0.0f;
model.vertices[i * 11 + 7] = 0.0f;
model.vertices[i * 11 + 8] = 0.0f;
}
Second step: Traverse the triangle, cross accumulation and add
We take the cross product of two edges of each triangle to obtain its face normal, then add it directly to each vertex’s normal components without normalizing it yet.
for (size_t i = 0; i < model.indices.size(); i += 3) {
uint32_t i0 = model.indices[i];
uint32_t i1 = model.indices[i + 1];
uint32_t i2 = model.indices[i + 2];
glm::vec3 v0(model.vertices[i0 * 11], model.vertices[i0 * 11 + 1],
model.vertices[i0 * 11 + 2]);
glm::vec3 v1(model.vertices[i1 * 11], model.vertices[i1 * 11 + 1],
model.vertices[i1 * 11 + 2]);
glm::vec3 v2(model.vertices[i2 * 11], model.vertices[i2 * 11 + 1],
model.vertices[i2 * 11 + 2]);
// 关键技巧:只算叉积,绝不在这一步 normalize!
// 叉积结果的模长 = 三角形面积 × 2
// 面积越大的三角形,对其共享顶点法线的影响权重就越大
glm::vec3 crossProduct = glm::cross(v1 - v0, v2 - v0);
// 累加到三个顶点上
model.vertices[i0 * 11 + 6] += crossProduct.x;
model.vertices[i0 * 11 + 7] += crossProduct.y;
model.vertices[i0 * 11 + 8] += crossProduct.z;
model.vertices[i1 * 11 + 6] += crossProduct.x;
model.vertices[i1 * 11 + 7] += crossProduct.y;
model.vertices[i1 * 11 + 8] += crossProduct.z;
model.vertices[i2 * 11 + 6] += crossProduct.x;
model.vertices[i2 * 11 + 7] += crossProduct.y;
model.vertices[i2 * 11 + 8] += crossProduct.z;
}
Why not normalize immediately after each cross product? Because the module length of the cross product of is exactly equal to the area of the parallelogram (i.e. twice the area of the triangle). If we normalize first and then accumulate, it is equivalent to giving all triangles the same 1:1 weight - small fragments and large planes contribute equally to the normals of the shared vertices, which will cause the normals of the dense areas of small triangles to be unreasonably biased. The cross product without normalize naturally carries area information. Large area is given priority, and the rendered model will be smoother and more natural.
Step 3: Final normalization with safety protection
for (size_t i = 0; i < model.vertices.size() / 11; ++i) {
float *n = &model.vertices[i * 11 + 6];
glm::vec3 normal(n[0], n[1], n[2]);
float len = glm::length(normal);
if (len > 1e-6f) {
normal = normal / len;
} else {
// 孤立点或退化三角形,给个默认朝上的法线,避免产生 NaN 黑斑
normal = glm::vec3(0.0f, 1.0f, 0.0f);
}
n[0] = normal.x;
n[1] = normal.y;
n[2] = normal.z;
}
The len > 1e-6f check here is a critical safety valve against NaN - in real 3MF models it is common to have several “orphan vertices” (no triangles referencing them), or several degenerate triangles with zero area (three vertices collinear). Doing normalize(vec3(0, 0, 0)) on these points will directly produce NaN, and NaN will spread like a plague in the GPU - a single NaN normals can turn the entire area into a weird black spot or flicker.
GPU resource mounting: Mesh’s VAO/VBO/EBO three-piece set
After the data is assembled in CPU memory, the next step is to upload it to GPU memory. This process is encapsulated in Mesh::setup and involves the creation and configuration of three core objects of OpenGL:
void Mesh::setup(const GeometryData& model) {
this->indexCount = static_cast<int>(model.indices.size());
// 1. 生成 GPU 资源句柄
glGenVertexArrays(1, &vao); // VAO: 顶点数组对象(记录所有属性配置状态)
glGenBuffers(1, &vbo); // VBO: 顶点缓冲对象(存放顶点数据)
glGenBuffers(1, &ebo); // EBO: 元素缓冲对象(存放索引数据)
// 2. 绑定 VAO —— 之后所有 VBO/EBO 的状态都会被 VAO "录像"
glBindVertexArray(vao);
// 3. 上传顶点数据到 VBO
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, model.getVertexBufferSize(),
model.vertices.data(), GL_STATIC_DRAW);
// 4. 上传索引数据到 EBO
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, model.getIndexBufferSize(),
model.indices.data(), GL_STATIC_DRAW);
int stride = VERTEX_ARRAY_SIZE * sizeof(float); // 11 * 4 = 44 字节
// 5. 配置顶点属性指针 —— 告诉 GPU 如何从 44 字节步长中拆解出各分量
// Position: location=0, 前 3 个 float
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)0);
// Color: location=1, 偏移 3 个 float
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride,
(void*)(3 * sizeof(float)));
// Normal: location=2, 偏移 6 个 float
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, stride,
(void*)(6 * sizeof(float)));
// TexCoord: location=3, 偏移 9 个 float (2 个分量)
glEnableVertexAttribArray(3);
glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, stride,
(void*)(9 * sizeof(float)));
// 6. 解绑(注意顺序:先 VAO,再 VBO/EBO)
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
}
Here are a few details worth noting:
GL_STATIC_DRAW: Tell the driver to “upload this data once and use it for a long time”, and the driver will put the data into the fastest memory area of the GPU accordingly. If our model changes in real time (such as skeletal animation), we should useGL_DYNAMIC_DRAW.- VAO is like a “video recorder”: Between
glBindVertexArray(vao)andglBindVertexArray(0), all VBO bindings, EBO bindings, and attribute pointer settings we do are completely recorded by VAO. When drawing later, only one lineglBindVertexArray(vao)can restore the entire state, which is extremely efficient. - Release GPU resources in destructor: The destructor of
Meshwill callglDeleteVertexArraysandglDeleteBuffersto return the video memory - although the OS will automatically recycle it when the process ends, in long-running applications, not manually releasing it will cause GPU memory leaks.
Camera class and MVP matrix pipeline
In OpenGL, rendering a frame of 3D requires the close cooperation of three matrices. We have encapsulated the Camera class in scene_renderer.h to manage all this:
class Camera {
public:
glm::mat4 view;
glm::mat4 projection;
glm::vec3 position;
glm::vec3 target;
Camera(int width, int height) {
position = glm::vec3(0.0f, 0.0f, 3.0f);
view = glm::lookAt(position, // 摄像机位置
glm::vec3(0.0f), // 注视目标 (原点)
glm::vec3(0.0f, 0.1f, 0.0f)); // 上方向
projection = glm::perspective(
glm::radians(45.0f), // 视野角
(float)width / (float)height, // 宽高比
0.1f, // 近裁剪面
100.0f); // 远裁剪面
}
void updateProjection(int width, int height) {
projection = glm::perspective(
glm::radians(45.0f),
(float)width / (float)height, 0.1f, 100.0f);
}
};
glm::lookAtGenerate View matrix - transform the world coordinate system into the camera coordinate system. The camera is placed at(0, 0, 3)looking at the origin, which means that all models need to be zoomed to a range of approximately 1.0 ~ 2.0 to be visible.glm::perspectiveGenerates a perspective projection matrix - projects 3D space onto a 2D screen.0.1fA near clipping plane means that objects within 0.1 units of the camera are clipped;100.0fA far clipping plane means that objects that are more than 100 units away are not visible.updateProjectionis called when the window size changes (setViewportSize) to ensure that the aspect ratio is always correct - otherwise the model will be stretched and deformed when switching between horizontal and vertical screens.
When the three matrices are drawn at each frame, they are assembled into MVP (Model-View-Projection) by Entity::draw and passed to the shader:
void Entity::draw(Shader* shader, const Camera& camera) {
// 分别传入 Model、View 矩阵和摄像机位置
shader->setUniformMat4f("u_Model", glm::value_ptr(transform));
shader->setUniformMat4f("u_View", glm::value_ptr(camera.view));
shader->setUniformVec3("u_ViewPos", camera.position);
// 组装 MVP = Projection × View × Model (注意:GLM 是列主序右乘)
glm::mat4 mvp = camera.projection * camera.view * transform;
shader->setUniformMat4f("u_MVP", glm::value_ptr(mvp));
mesh->draw();
}
Why do we need to pass u_Model and u_View separately instead of just passing the synthesized u_MVP? Because the fragment shader needs the Model matrix to transform the normals vector (otherwise the normals direction will be distorted along with the Projection), and u_ViewPos is used to calculate the viewing direction - if you want to add specular reflection (Blinn-Phong) later, this value is essential.
shader: from vertices to pixels
vertex shader
Our mesh_shader.vert is very streamlined and only does two things - transform coordinates and pass interpolated data:
#version 300 es
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
layout(location = 2) in vec3 aNormal;
layout(location = 3) in vec2 aTexCoords;
uniform mat4 u_MVP;
uniform mat4 u_Model;
out vec3 vNormal;
out vec3 vColor;
out vec2 vTexCoords;
void main() {
gl_Position = u_MVP * vec4(aPos, 1.0);
vNormal = mat3(u_Model) * aNormal;
vColor = aColor;
vTexCoords = aTexCoords;
}
The N in layout(location = N) corresponds to the attribute slot we configured through glVertexAttribPointer(N, ...) in Mesh::setup.
The line worth noting here is vNormal = mat3(u_Model) * aNormal. Why use mat3 instead of mat4? Because normals is the direction vector, it cannot be affected by the translation component of the fourth column in the 4×4 matrix. Taking the 3×3 submatrix in the upper left corner of the Model matrix, only the rotation and scaling are retained - so that the normals can correctly follow the model rotation without being “biased” by translation. Strictly speaking, if the Model matrix contains non-proportional scaling, the inverse transposed matrix (transpose(inverse(mat3(u_Model)))) should be used, but in our scenario the scaling is proportional, so directly taking mat3 is enough.
Fragment shader: Lambertian lighting model
If the vertex shader determines “where” the model is, then the fragment shader determines “what” the model looks like. Our mesh_shader.frag implements the classic Lambertian diffuse reflection + ambient light lighting model:
#version 300 es
precision highp float;
in vec3 vNormal;
in vec3 vColor;
in vec2 vTexCoords;
out vec4 outColor;
uniform mat4 u_View;
uniform vec3 u_ViewPos;
void main() {
// 定义一个从右上方照射的定向光
vec3 lightDir = normalize(vec3(0.5, 1.0, 0.3));
// 环境光分量:保证模型暗部也能看清轮廓
float ambient = 0.2;
// 漫反射分量:法线与光线方向的点积(余弦值)
float diff = max(dot(normalize(vNormal), lightDir), 0.0);
// 最终颜色 = 顶点色 × (漫反射 + 环境光)
vec3 result = vColor * (diff + ambient);
outColor = vec4(result, 1.0);
}
Parse line by line:
lightDir = normalize(vec3(0.5, 1.0, 0.3)): Defines a parallel light source that illuminates from the upper right front.(0.5, 1.0, 0.3)was chosen to simulate natural “top right” lighting, so that all sides of the model can obtain clear-cut light and dark changes.ambient = 0.2: Ambient light coefficient. Even if a face is completely facing away from the light source (diff = 0), it can maintain 20% of the base brightness and avoid pure black dead shadows.max(dot(normalize(vNormal), lightDir), 0.0): This is Lambert’s cosine law - Surface brightness is proportional to the cosine of the angle between normals and the direction of the light.max(..., 0.0)Ensure that back normals (negative dot products) do not produce negative lighting values.vColor * (diff + ambient): Applies light intensity to vertex color. Since we set all vertices white inmodel_loader.cpp(1, 1, 1), the final effect is a grayscale light and shadow of a pure white model.
Although this lighting model is simple, it is completely sufficient for the visualization of 3MF engineering parts - industrial modeling pays more attention to the recognition of geometric shapes, while exquisite PBR material rendering is secondary.
SceneRenderer: Render loop and gesture interaction
Single frame rendering process
After all modules are ready, SceneRenderer::render() is responsible for the rendering execution of each frame:
void SceneRenderer::render() {
// 1. 开启深度测试,确保近处的三角形遮挡远处的
glEnable(GL_DEPTH_TEST);
// 2. 清空颜色缓冲和深度缓冲
glClearColor(1.0f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 3. 如果模型已加载,执行绘制
if (entity) {
shader->bind(); // 激活着色器程序
entity->draw(shader, camera); // 设置 Uniforms + 绘制
shader->unBind(); // 解绑着色器
}
}
GL_DEPTH_TESTis a function that must be turned on in 3D rendering. Without it, the GPU will determine the front and rear occlusion relationship according to the submission order of the triangles - the ones drawn later will always overwrite the ones drawn first, and you will see the weird effect of the model “turning over” or “crossing the mold”.if (entity)The empty safety judgment ensures that it will not crash when the model has not been loaded - at this time, only the screen will be cleared, and the Texture on the Flutter side will display a pure red background.- Shader’s
bind/unBindfollows the OpenGL state machine model:bindis equivalent to “telling the GPU to use this shader for the next drawing”,unBindis “reset” to avoid affecting other possible subsequent drawing calls.
Model loading and automatic scaling
When the user selects a .3mf file via the file selector, SceneRenderer::loadModel completes loading and initial scaling:
void SceneRenderer::loadModel(const std::vector<uint8_t> &data) {
Result<GeometryData> result = ModelLoader::LoadFromBuffer(data);
if (result.success) {
Mesh *mesh = new Mesh();
mesh->setup(*result.data);
entity = new Entity();
entity->mesh = std::shared_ptr<Mesh>(mesh);
entity->transform = glm::mat4(1.0f); // 初始化为单位矩阵
entity->transform =
glm::scale(entity->transform, glm::vec3(0.01f, 0.01f, 0.01f));
}
}
glm::scale(..., vec3(0.01f)) This line may seem inconspicuous, but it is actually the key to making the model display correctly. The coordinate units of 3MF are usually mm - an ordinary phone case model may have dimensions of 150mm × 75mm × 10mm, which means the maximum coordinate value reaches 150. And our Camera is only 3 units from the origin (vec3(0, 0, 3)), and the far clipping plane is only 100.0. Without scaling, the 150mm model would either exceed the cropping range and be cut off, or it would be so large that it would occupy the entire viewing frustum and become a white wall.
The scaling factor of 0.01 will be 150mm → 1.5 units, which just falls within the reasonable viewing range of the Camera. A more ideal approach is to dynamically calculate the scaling factor based on the Bounding Box to ensure that models of any size can be fully rendered - this is a good direction for subsequent optimization.
gesture rotation
Every time the touch screen (onPanUpdate of model_file_picker_route.dart) is triggered in Flutter, the rotateEntity(float x, float y) we call in SceneRenderer will directly use the glm math library to manipulate the Model matrix to achieve the 3D rotation of the model:
void SceneRenderer::rotateEntity(float x, float y) {
// 绕世界 Y 轴旋转(水平滑动)
entity->transform = glm::rotate(entity->transform, x, glm::vec3(0, 1, 0));
// 绕世界 X 轴旋转(垂直滑动)
entity->transform = glm::rotate(entity->transform, y, glm::vec3(1, 0, 0));
}
Since each rotation is based on the current transform matrix multiplication, each user’s swipe is superimposed on the basis of the previous rotation state - this allows the model to be freely rotated to any angle. This rotation amount is captured by GestureDetector.onPanUpdate on the Flutter side and multiplied by sensitivity = 0.01 to control the sensitivity.
Full link architecture overview
Let us finally use a panorama to review the complete link from file to pixel:
3MF 文件 (.3mf)
↓ DetectFormat() — 魔数嗅探 (PK\x03\x04 = ZIP)
↓ PReader::ReadFromBuffer() — lib3mf 宽松解析
↓ PMeshObject — 获取网格对象
↓ ExtractMeshData() — 11-float 紧凑顶点布局
↓ ComputeNormals() — 面积加权平滑法线
GeometryData (CPU 内存)
↓ Mesh::setup() — glBufferData 上传至 GPU 显存
↓ VAO/VBO/EBO 三件套
Entity + Camera + Shader
↓ Entity::draw() — 组装 MVP 矩阵, 设 Uniforms
↓ mesh_shader.vert — 坐标变换 + 法线旋转
↓ mesh_shader.frag — Lambertian 光照计算
↓ glDrawElements(GL_TRIANGLES, ...) — 光栅化
屏幕像素 (SurfaceTexture → Flutter Texture)
Each layer is designed with “zero copy” or “minimum overhead” - lib3mf parses the data in C++, and the vertices.data() pointer is directly passed to glBufferData for upload to the GPU, without any memory transfer across language boundaries.
Conclusion
From the underlying magic number sniffing verification file format using lib3mf, to the memory-friendly flattening and parsing vertices, to the rigorous and pit-proof “area weighted smoothing normals algorithm”, and then uploading data zero copy to the GPU’s VAO/VBO/EBO three-piece set through Mesh::setup, and then building a complete MVP in the Camera class Matrix pipeline, and finally the Lambertian lighting model of the vertex shader and fragment shader completes the final pixel shading - we have completely established the entire pipeline from file to visual rendering.
This is why we always insist on lowering the rendering bottom layer to C++: only extremely precise memory control and zero-copy flow can ensure that the 3D structure application on the mobile phone can display any extremely complex .3mf engineering manuscript smoothly!