1. 项目概述:当3D图形遇上微控制器
最近在整理工作室的物料,翻出来几块闲置的OLED12864屏幕和几片Arduino Uno。看着这些硬件,一个念头冒了出来:能不能用这点“简陋”的硬件,跑一个最简单的3D线框渲染引擎?听起来有点天方夜谭,毕竟Arduino Uno只有2KB的RAM和16MHz的主频,而OLED12864的分辨率也只有128x64像素。但正是这种极致的硬件限制,让这个项目充满了挑战和乐趣。它不是一个追求画面炫酷的图形demo,而是一次对计算机图形学基础原理的“返璞归真”的实践。通过这个项目,你能亲手从零构建一个3D世界的数学描述,并看着它在一个巴掌大的屏幕上“活”过来,这种成就感是直接调用成熟图形库无法比拟的。
这个项目本质上是一个软件渲染器(Software Renderer),完全依靠CPU进行所有的顶点变换、投影和光栅化计算,不依赖任何硬件加速。我们将使用Arduino C++来编写所有代码,在OLED12864这块单色、低分辨率的屏幕上,实时渲染出旋转的立方体、棱锥等简单3D模型的线框动画。它非常适合对嵌入式开发、计算机图形学感兴趣的朋友,作为入门实践。你不需要昂贵的显卡,只需要一块最常见的Arduino开发板和一块几十块钱的屏幕,就能窥见3D游戏和CAD软件背后最核心的那套数学与逻辑。
2. 核心思路与架构设计
要在资源如此紧张的Arduino上实现3D渲染,我们必须做出极其精简和高效的设计。整个引擎的核心思路可以概括为:用最基础的线性代数知识(主要是矩阵和向量运算),在代码中构建一个虚拟的3D世界,然后通过一系列数学变换,将这个世界“拍扁”到我们的2D屏幕上。
2.1 为什么选择线框渲染?
在Arduino Uno的硬件条件下,填充多边形(即绘制实心面)是几乎不可能完成的任务。这涉及到扫描线转换、深度缓冲(Z-Buffer)等更复杂的算法和更多的内存开销。我们的RAM根本不足以存储一个128x64的深度缓冲区(即使每个像素用1字节,也需要8KB内存)。因此,退而求其次,我们只绘制模型的边,也就是“线框渲染”。这大大简化了问题:
- 计算量小:只需处理顶点和线段,无需处理像素填充。
- 内存占用低:只需要存储顶点坐标和边的连接关系。
- 逻辑清晰:非常适合理解3D图形管线中最核心的“顶点变换”部分。
2.2 系统架构与数据流
整个渲染引擎可以抽象为一个简化的图形管线,数据流如下:
3D模型定义 (顶点列表、边列表) ↓ 模型变换 (旋转、缩放、平移) ↓ 视图变换 (虚拟相机摆放) ↓ 投影变换 (3D到2D的映射) ↓ 视口变换 (映射到屏幕坐标) ↓ 光栅化 (绘制2D线段) ↓ OLED12864显示我们的代码将围绕这个数据流进行组织。核心数据结构将包括:
- Vector3:一个简单的结构体,用于存储一个顶点的x, y, z坐标。
- Edge:一个结构体,存储一条边所连接的两个顶点的索引。
- Model:一个结构体或类,包含一个顶点数组和一个边数组,共同定义一个3D模型。
2.3 数学库的轻量化实现
我们无法使用Eigen或glm这样的大型数学库。所有数学运算都必须自己实现,并且要极度优化。我们将实现一个最小功能的Matrix4x4(4x4矩阵)类和相关的向量运算函数。关键运算包括:
- 矩阵与矩阵相乘
- 矩阵与向量相乘(用于变换顶点)
- 绕X、Y、Z轴旋转的矩阵生成函数
- 平移矩阵生成函数
- 透视投影矩阵生成函数
这里有一个重要的取舍:为了速度和代码简洁,我们通常使用单精度浮点数(float)。但在某些对性能要求极高的场合,甚至可以考虑使用定点数。不过对于入门项目,float在Arduino Uno上经过精心编码后,渲染一个立方体达到10FPS以上的帧率是可行的。
3. 硬件连接与驱动准备
工欲善其事,必先利其器。在开始写代码之前,我们需要把硬件搭建起来。
3.1 硬件清单与连接
- 主控:Arduino Uno R3 (或任何兼容板,如Nano)。这是我们的“大脑”,负责所有计算。
- 显示:0.96英寸 OLED12864显示屏。这里特指使用SSD1306驱动芯片的I2C接口版本,它有4个引脚,连接最为简单。
- 注意:OLED12864也有SPI接口的版本,引脚更多,速度更快,但接线复杂。I2C版本完全满足本项目需求,且节省IO口。
- 连接线:若干杜邦线。
接线方式(I2C): OLED模块的4个引脚通常标为:VCC, GND, SCL, SDA。
- VCC-> Arduino5V
- GND-> ArduinoGND
- SCL-> ArduinoA5(在Uno上,A4是SDA,A5是SCL。这是硬件I2C引脚)
- SDA-> ArduinoA4
注意:有些OLED模块的工作电压是3.3V,如果连接5V可能会损坏。请务必确认你的模块电压。通常,支持5V的模块会有一个小的LDO稳压芯片。如果不确定,稳妥起见先接3.3V。如果屏幕不亮或显示异常,再检查电压问题。
3.2 软件库安装
我们将使用一个非常流行的OLED驱动库:Adafruit_SSD1306以及它所依赖的Adafruit_GFX图形基础库。它们封装了底层通信和基本绘图函数,让我们可以专注于3D逻辑。
在Arduino IDE中安装库的步骤:
- 点击工具->管理库...。
- 在搜索框中输入“Adafruit SSD1306”。
- 找到由Adafruit发布的库,点击安装。
- 安装过程中,它会提示需要安装依赖库
Adafruit GFX Library,点击“安装所有”即可。
安装完成后,你可以在文件->示例中找到Adafruit SSD1306的示例代码,可以用来测试屏幕是否正常工作。
3.3 基础显示测试
在深入3D引擎之前,我们先写一个简单的测试程序,确保屏幕驱动和基础画线功能正常。这能排除硬件连接和库安装的问题。
#include <Wire.h> #include <Adafruit_GFX.h> #include <Adafruit_SSD1306.h> #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 #define OLED_RESET -1 // 重置引脚,共享Arduino复位引脚则填-1 Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); void setup() { Serial.begin(9600); if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { // I2C地址通常是0x3C或0x3D Serial.println(F("SSD1306 allocation failed")); for(;;); // 卡死 } display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0,0); display.println("OLED Ready!"); display.display(); // 将缓冲区内容刷到屏幕 delay(2000); } void loop() { display.clearDisplay(); // 画一条对角线 display.drawLine(0, 0, SCREEN_WIDTH-1, SCREEN_HEIGHT-1, SSD1306_WHITE); // 画一个矩形框 display.drawRect(10, 10, 50, 30, SSD1306_WHITE); display.display(); delay(1000); }将代码上传到Arduino,如果屏幕能显示文字、线条和矩形,说明硬件和基础库工作正常。这里关键的drawLine函数,将是我们后续绘制3D线框的唯一工具。
4. 3D数学基础与核心代码实现
这是整个项目的灵魂所在。我们将一步步构建一个微型的3D数学库。
4.1 定义基础数据结构
首先,在代码开头,定义我们最核心的数据结构。
// 3D向量,用于表示一个顶点或一个方向 struct Vector3 { float x, y, z; Vector3() : x(0), y(0), z(0) {} Vector3(float x_, float y_, float z_) : x(x_), y(y_), z(z_) {} }; // 4x4矩阵,用于表示所有变换(旋转、平移、投影) class Matrix4x4 { public: float m[4][4] = {0}; // 初始化为零矩阵 // 设置为单位矩阵 void setIdentity() { for(int i=0; i<4; i++) { for(int j=0; j<4; j++) { m[i][j] = (i == j) ? 1.0f : 0.0f; } } } // 矩阵乘法:this = this * other Matrix4x4& multiply(const Matrix4x4& other) { Matrix4x4 result; for(int i=0; i<4; i++) { for(int j=0; j<4; j++) { result.m[i][j] = m[i][0]*other.m[0][j] + m[i][1]*other.m[1][j] + m[i][2]*other.m[2][j] + m[i][3]*other.m[3][j]; } } *this = result; return *this; } // 变换一个3D点(齐次坐标,w=1) Vector3 transformPoint(const Vector3& v) const { Vector3 out; out.x = m[0][0]*v.x + m[0][1]*v.y + m[0][2]*v.z + m[0][3]; // *1 out.y = m[1][0]*v.x + m[1][1]*v.y + m[1][2]*v.z + m[1][3]; out.z = m[2][0]*v.x + m[2][1]*v.y + m[2][2]*v.z + m[2][3]; // 通常这里会除以w,但我们的变换矩阵设计会保证w保持为1,除非是投影变换 return out; } };4.2 实现变换矩阵生成函数
接下来,我们创建函数来生成各种变换矩阵。这是将3D空间中的物体进行摆放、旋转和投影的关键。
// 生成绕X轴旋转的矩阵 (角度单位为弧度) Matrix4x4 createRotationX(float angle) { Matrix4x4 mat; mat.setIdentity(); float c = cos(angle); float s = sin(angle); mat.m[1][1] = c; mat.m[1][2] = -s; mat.m[2][1] = s; mat.m[2][2] = c; return mat; } // 生成绕Y轴旋转的矩阵 Matrix4x4 createRotationY(float angle) { Matrix4x4 mat; mat.setIdentity(); float c = cos(angle); float s = sin(angle); mat.m[0][0] = c; mat.m[0][2] = s; mat.m[2][0] = -s; mat.m[2][2] = c; return mat; } // 生成绕Z轴旋转的矩阵 Matrix4x4 createRotationZ(float angle) { Matrix4x4 mat; mat.setIdentity(); float c = cos(angle); float s = sin(angle); mat.m[0][0] = c; mat.m[0][1] = -s; mat.m[1][0] = s; mat.m[1][1] = c; return mat; } // 生成平移矩阵 Matrix4x4 createTranslation(float tx, float ty, float tz) { Matrix4x4 mat; mat.setIdentity(); mat.m[0][3] = tx; mat.m[1][3] = ty; mat.m[2][3] = tz; return mat; } // 生成透视投影矩阵 // fov: 垂直视野角(弧度) // aspect: 屏幕宽高比 (width/height) // znear, zfar: 近裁剪面和远裁剪面距离 Matrix4x4 createPerspectiveProjection(float fov, float aspect, float znear, float zfar) { Matrix4x4 mat; mat.setIdentity(); float f = 1.0f / tan(fov / 2.0f); mat.m[0][0] = f / aspect; mat.m[1][1] = f; mat.m[2][2] = (zfar + znear) / (znear - zfar); mat.m[2][3] = (2.0f * zfar * znear) / (znear - zfar); mat.m[3][2] = -1.0f; // 这个-1是关键,它将z值“压”进w分量,为后续透视除法做准备 mat.m[3][3] = 0.0f; return mat; }核心原理解释:
createPerspectiveProjection函数生成的矩阵是引擎中最精妙的部分。它做了两件重要的事:1) 根据视野和宽高比进行缩放,模拟镜头效果。2) 通过矩阵运算,将z坐标信息编码,并让w分量等于-z(因为m[3][2] = -1)。之后,当我们用这个矩阵变换一个点(x,y,z,1)后,会得到(x', y', z', w'),其中w' = -z。进行透视除法((x'/w', y'/w'))后,距离相机越远(z负得越多,w‘越大)的点,其2D坐标就越靠近屏幕中心,从而产生“近大远小”的透视效果。
4.3 定义3D模型与渲染管线
现在,我们可以定义一个立方体模型,并编写主渲染循环。
// 定义一个立方体模型 const int CUBE_VERTEX_COUNT = 8; Vector3 cubeVertices[CUBE_VERTEX_COUNT] = { Vector3(-1, -1, -1), // 0 左-下-后 Vector3( 1, -1, -1), // 1 右-下-后 Vector3( 1, 1, -1), // 2 右-上-后 Vector3(-1, 1, -1), // 3 左-上-后 Vector3(-1, -1, 1), // 4 左-下-前 Vector3( 1, -1, 1), // 5 右-下-前 Vector3( 1, 1, 1), // 6 右-上-前 Vector3(-1, 1, 1) // 7 左-上-前 }; const int CUBE_EDGE_COUNT = 12; int cubeEdges[CUBE_EDGE_COUNT][2] = { {0,1}, {1,2}, {2,3}, {3,0}, // 后面的面 {4,5}, {5,6}, {6,7}, {7,4}, // 前面的面 {0,4}, {1,5}, {2,6}, {3,7} // 连接前后的棱 }; // 全局变换矩阵和变量 Matrix4x4 worldMatrix; // 模型变换:旋转、缩放、平移 Matrix4x4 viewMatrix; // 视图变换:相机位置和朝向 Matrix4x4 projMatrix; // 投影矩阵 Matrix4x4 transformMatrix; // 最终变换矩阵 = proj * view * world float angle = 0.0f; // 用于动画的旋转角度 void setup() { // ... OLED初始化代码同上 ... // 初始化变换矩阵 worldMatrix.setIdentity(); viewMatrix.setIdentity(); // 将相机向后移动一些,以便观察物体 viewMatrix = createTranslation(0, 0, -5); // 设置透视投影 float fov = radians(60); // 60度视野 float aspect = (float)SCREEN_WIDTH / (float)SCREEN_HEIGHT; projMatrix = createPerspectiveProjection(fov, aspect, 0.1f, 100.0f); } void loop() { // 1. 清屏 display.clearDisplay(); // 2. 更新世界矩阵(让立方体旋转) angle += 0.02f; // 每帧增加一点角度 worldMatrix.setIdentity(); worldMatrix.multiply(createRotationY(angle)); worldMatrix.multiply(createRotationX(angle * 0.7f)); // 绕X轴也转,但速度不同,产生复合旋转 // 3. 计算最终变换矩阵 transformMatrix = projMatrix; transformMatrix.multiply(viewMatrix); transformMatrix.multiply(worldMatrix); // 4. 变换所有顶点并投影到2D屏幕空间 Vector2D screenPoints[CUBE_VERTEX_COUNT]; // 我们需要一个2D向量来存储屏幕坐标 for(int i=0; i<CUBE_VERTEX_COUNT; i++) { // 应用变换矩阵 Vector3 transformed = transformMatrix.transformPoint(cubeVertices[i]); // 透视除法:将齐次坐标转换为3D坐标 (x/w, y/w, z/w) // 注意:我们的transformPoint没有处理w,因为投影矩阵的最后一行使w=-z。 // 实际上,更严谨的做法是进行完整的齐次坐标除法。 // 这里我们简化处理:假设变换后的点v,其w分量就是 -v.z (由投影矩阵保证)。 float w = -transformed.z; // 或者更准确地说,是变换后点的第四个分量 if(w > 0.001f) { // 避免除以零或非常小的数 float invW = 1.0f / w; float screenX = (transformed.x * invW + 1.0f) * 0.5f * SCREEN_WIDTH; float screenY = (1.0f - (transformed.y * invW + 1.0f) * 0.5f) * SCREEN_HEIGHT; // Y轴翻转 screenPoints[i].x = (int)screenX; screenPoints[i].y = (int)screenY; } else { // 如果点在相机后面,可以标记为无效 screenPoints[i].x = -1; screenPoints[i].y = -1; } } // 5. 绘制所有边 for(int i=0; i<CUBE_EDGE_COUNT; i++) { int idx0 = cubeEdges[i][0]; int idx1 = cubeEdges[i][1]; // 检查两个顶点是否都在屏幕内(简单的裁剪) if(screenPoints[idx0].x >=0 && screenPoints[idx0].y >=0 && screenPoints[idx1].x >=0 && screenPoints[idx1].y >=0) { display.drawLine(screenPoints[idx0].x, screenPoints[idx0].y, screenPoints[idx1].x, screenPoints[idx1].y, SSD1306_WHITE); } } // 6. 显示 display.display(); // 7. 控制帧率 delay(16); // 约60FPS }这段代码已经是一个完整的、可运行的3D线框立方体渲染器。上传到Arduino后,你应该能看到一个在屏幕上旋转的立方体线框图。
5. 性能优化与高级技巧
上面的基础版本可以运行,但在Arduino Uno上可能帧率较低。为了获得更流畅的动画,我们需要进行一些关键的优化。
5.1 计算优化:查表法与定点数
三角函数查表:sin和cos函数在微控制器上是计算开销很大的浮点运算。对于旋转动画,我们可以预先计算一个正弦/余弦表。
// 预计算sin/cos表,精度为1度,共360个值 #define SIN_TABLE_SIZE 360 float sinTable[SIN_TABLE_SIZE]; float cosTable[SIN_TABLE_SIZE]; void initTrigTables() { for(int i=0; i<SIN_TABLE_SIZE; i++) { float rad = radians(i); sinTable[i] = sin(rad); cosTable[i] = cos(rad); } } // 快速获取sin/cos值(角度取模) float fastSin(float degree) { int idx = ((int)degree % 360 + 360) % 360; // 处理负角度 return sinTable[idx]; } float fastCos(float degree) { int idx = ((int)degree % 360 + 360) % 360; return cosTable[idx]; } // 在createRotationX/Y/Z函数中使用fastSin和fastCos(注意参数需转换为角度)定点数运算:浮点数运算比整数慢。我们可以使用定点数,例如,将小数部分放大2^16倍,用int32_t来存储。加减法直接进行,乘除法需要额外的移位操作。这对于顶点坐标变换能带来显著提升,但代码会变得复杂。对于入门项目,如果浮点运算帧率尚可接受,可以暂不采用。
5.2 渲染优化:背面剔除与视锥裁剪
背面剔除(Backface Culling):对于封闭的实体模型(如立方体),我们看不到背对相机的面。在线框渲染中,虽然画的是边,但我们可以通过判断一个面的法向量与视线方向的关系,来决定是否绘制构成该面的所有边。这能减少近一半的绘制调用。实现需要为模型定义“面”而不仅仅是“边”,并计算面法向量。
视锥裁剪(Frustum Culling):我们只绘制在相机视野范围内的物体或顶点。简单的做法是,在透视除法后,只绘制屏幕坐标在[0, width]和[0, height]范围内的点。更高级的可以是在齐次坐标空间进行裁剪,但这更复杂。我们代码中第5步的简单检查就是一种“屏幕空间裁剪”。
5.3 代码结构优化:矩阵与向量操作内联
将关键的矩阵乘法、向量变换函数声明为inline,并尽量减少函数调用开销。例如,可以将transformPoint函数的关键计算直接写在循环里,避免函数调用的消耗。对于Arduino这种资源受限的环境,循环内的微小优化累积起来效果明显。
5.4 使用更快的显示库或通信方式
Adafruit_GFX的drawLine函数功能强大但可能不是最快的。对于纯粹的线段绘制,我们可以直接操作Adafruit_SSD1306的底层缓冲区,实现一个更快的Bresenham画线算法。此外,如果使用SPI接口的OLED,刷新速度会比I2C快很多,但需要更多引脚和不同的库。
6. 功能扩展与创意玩法
基础立方体跑起来后,你可以尝试很多有趣的扩展。
6.1 加载更多模型
不要局限于立方体。你可以用代码定义其他模型,比如四面体、八面体、甚至一个简单的太空飞船。
// 定义一个四面体 Vector3 pyramidVertices[4] = { Vector3( 0, 1, 0), // 顶部 Vector3(-1, -1, 1), // 左前 Vector3( 1, -1, 1), // 右前 Vector3( 0, -1, -1) // 后面 }; int pyramidEdges[6][2] = { {0,1}, {0,2}, {0,3}, // 从顶部到底部三个顶点的边 {1,2}, {2,3}, {3,1} // 底部三角形的边 };你甚至可以设计一个简单的模型文件格式(比如每行一个顶点坐标,然后一个边列表),从SD卡读取,实现动态加载。
6.2 添加交互控制
通过Arduino的模拟输入(如电位器)或数字输入(如按钮)来控制3D场景。
- 电位器A:控制绕Y轴旋转速度。
- 电位器B:控制绕X轴旋转速度。
- 按钮:切换不同的模型(立方体、棱锥等)。
这只需要在loop()中读取analogRead()和digitalRead()的值,然后用来更新angle变量或模型索引即可。
6.3 实现简单的动画与场景
让物体不只是旋转,还可以平移、缩放。你可以让一个立方体沿着一条路径移动,或者让多个物体以不同的速度和方向旋转,形成一个简单的场景。
// 在loop()中更新多个物体的变换矩阵 float time = millis() / 1000.0f; // 获取运行时间(秒) // 物体1:绕Y轴旋转 matrix1 = createRotationY(time); // 物体2:在X-Z平面做圆周运动 float orbitRadius = 3.0f; float posX = orbitRadius * cos(time); float posZ = orbitRadius * sin(time); matrix2 = createTranslation(posX, 0, posZ); matrix2.multiply(createRotationY(time * 2.0f)); // 同时自转6.4 尝试“伪3D”特效
虽然我们做的是线框渲染,但可以加入一些视觉增强。
- 深度暗示:用不同的亮度或虚线样式绘制距离相机较远的线。这需要根据顶点的z值(变换后的)来调整绘制颜色或模式。单色OLED可以通过绘制点划线来实现。
- 简单的消隐(Hidden Line Removal):这是一个更复杂的课题。一个取巧的办法是“画家算法”,即按照物体或面的深度(离相机的平均距离)从远到近绘制。后绘制的线会覆盖先绘制的,模拟出前面的线遮挡后面线的效果。这对于凸多面体(如立方体)很有效。
7. 常见问题与调试技巧
在实现过程中,你肯定会遇到各种奇怪的现象。这里记录一些典型的坑和解决方法。
7.1 屏幕显示异常(花屏、闪烁、不显示)
- 检查接线:这是最常见的问题。确认VCC/GND没有接反,SDA/SCL是否接对(Uno上是A4/A5)。
- 检查I2C地址:OLED的I2C地址通常是
0x3C,但也有部分是0x3D。如果初始化失败,可以尝试扫描I2C地址。 - 电源问题:确保电源充足。如果使用USB供电且连接了其他模块,可能导致电压不足。尝试单独给Arduino供电。
- 库冲突:确保只包含了必要的库。有时多个图形库会冲突。
7.2 3D模型渲染错乱(变形、撕裂、位置不对)
- 顶点坐标范围:确保你的模型顶点坐标在一个合理的范围内(例如-1到1)。如果模型太大,可能会在投影变换后超出屏幕。
- 投影矩阵参数:检查
fov、aspect、znear、zfar参数。znear不能为0或负数。aspect应该是屏幕的宽高比(128/64=2.0)。如果物体显示得太大或太小,调整fov或相机距离(viewMatrix中的平移Z值)。 - 透视除法:这是最容易出错的一步。确认你正确处理了齐次坐标
w分量。在我们的简化代码中,w就是-transformed.z。确保除法前检查w是否大于一个很小的正数(如0.001),以避免除以零。 - 屏幕坐标映射:我们的映射公式
(x/w + 1) * 0.5 * width将NDC(标准化设备坐标,范围[-1,1])映射到屏幕坐标[0, width]。Y轴需要翻转,因为屏幕坐标原点在左上角。 - 矩阵乘法顺序:记住矩阵乘法不满足交换律。正确的世界-视图-投影组合顺序是:
projection * view * world * vertex。我们的代码中通过transformMatrix.multiply(viewMatrix)和transformMatrix.multiply(worldMatrix)来实现,因为我们是左乘。
7.3 性能问题(帧率低、卡顿)
- 使用
delay()控制帧率:delay(16)约60FPS,但实际渲染可能更慢。可以用millis()做非阻塞的帧率控制。 - 简化模型:减少顶点和边的数量。
- 启用编译器优化:在Arduino IDE的“工具”菜单中,将“优化”选项设置为“更快(-O3)”。
- 进行前述的优化:查表、简化计算、减少浮点运算。
7.4 内存不足(编译失败或运行异常)
- 全局变量过大:预计算的三角函数表、模型顶点数组都会占用RAM。Arduino Uno只有2KB。尽量减少全局数组的大小。对于模型数据,可以考虑使用
PROGMEM关键字将其存储在Flash中,使用时再读取,但这会降低渲染速度。 - 栈溢出:避免在函数内定义大型局部数组(如
Vector3 transformedVertices[100])。尽量使用全局或静态数组。 - 使用
F()宏存储字符串:如果调试时有Serial.print,用Serial.print(F("Debug info"))将字符串常量保存在Flash中,节省RAM。
调试时,最有力的工具是串口监视器。在关键步骤打印出变量值(如顶点变换前后的坐标、屏幕坐标),可以帮助你快速定位是哪个变换环节出了问题。例如,在透视除法后打印几个顶点的屏幕坐标,看它们是否在预期的[0,127]和[0,63]范围内。