基于 OpenGL ES 2.0 的 iOS 3D 魔方应用 — 从架构到像素级渲染
1. 项目概述
MagicCube 是由开发者 lihua liu(萤火虫工作室) 于 2012 年发布的一款 iOS 3D 魔方应用。项目采用 MIT 许可证,允许用于商业和非商业用途。
它使用 OpenGL ES 2.0 可编程管线 从底层构建了整个 3D 渲染体系:
项目地址 https://github.com/yinghuochong/MagicCube
游戏功能
- 触摸滑动旋转魔方面
- 双指缩放与旋转整体
- 摇晃手机自动打乱
- 6 种可选贴图纹理
- 计时与计步统计
技术栈
- OpenGL ES 2.0 可编程管线
- CADisplayLink 渲染循环
- GLSL Vertex/Fragment Shader
- 离屏 Framebuffer 像素拾取
- MRC 手动内存管理
- OAuth 1.0 腾讯微博集成
2. 整体架构设计
项目采用 MVC + OpenGL 渲染管道 的架构模式:
AppDelegate
|
+-- StartViewController (纹理选择 + 微博分享)
| . UITableView
| . OAuthManager
|
+-- ViewController (3D渲染核心 + 触摸交互)
. CADisplayLink 渲染循环
. EAGLView (OpenGL 画布)
|
+-- GameLogic (魔方状态管理)
| . 初始化、着色、旋转判断、Slice旋转、完成检测
|
+-- MatrixTools (4x4 矩阵运算)
| . 旋转/平移/缩放/投影/乘法
|
+-- YHCOpenGL* (Shader封装)
. Program 编译链接
. 纹理加载
. 文字渲染 (FontImporter + TextString + TextStringManager)
核心思想是 数据驱动渲染:27 个 Cube 结构体构成魔方的唯一数据源,ViewController 每帧读取这些数据绘制到屏幕,GameLogic 在旋转完成时更新数据。
3. 应用入口与启动流程
3.1 main.m — 标准入口
int main(int argc, char *argv[])
{
@autoreleasepool {
return UIApplicationMain(argc, argv, nil,
NSStringFromClass([AppDelegate class]));
}
}
3.2 PaintingWindow — 摇晃检测
继承自 UIWindow,重写运动事件方法,通过 NSNotification 将摇晃事件通知给任意监听者:
- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event
{
if (motion == UIEventSubtypeMotionShake) {
[[NSNotificationCenter defaultCenter]
postNotificationName:@"shake" object:self];
}
}
设计思路: 用通知而非 Delegate 传递摇晃事件,使得 ViewController 不需要持有 PaintingWindow 的引用,实现模块解耦。
3.3 AppDelegate — 双页面架构
应用有两个页面:StartViewController(纹理选择启动页)和 ViewController(3D 魔方主页面),通过 presentModalViewController / dismissModalViewControllerAnimated 切换。
4. 魔方数据结构 — Cube 结构体
整个项目的核心数据结构。27 个 C 语言结构体实例(静态数组 static Cube cubes[27])代表 3x3x3 魔方的每一个小方块。
struct Cube {
GLfloat _vertices[78]; // 26个顶点 x 3坐标(x,y,z)
GLfloat _textureCoords[52]; // 26个顶点 x 2纹理坐标(s,t)
GLbyte _row; // 行坐标 (0~2)
GLbyte _col; // 列坐标 (0~2)
GLbyte _layer; // 层坐标 (0~2)
GLfloat _rotateMatrix[16]; // 累积旋转矩阵 (4x4, 列主序)
GLubyte _colors[104]; // 选择颜色编码 (顶点着色)
GLint _curIndex; // 当前索引 0~26
};
typedef struct Cube Cube;
各字段详解
| 字段 | 大小 | 含义 |
|---|---|---|
_vertices |
78 floats | 26 个顶点的 XYZ 坐标,用于 glDrawArrays(GL_TRIANGLE_STRIP, 0, 26)
|
_textureCoords |
52 floats | 每个顶点对应的 (s, t) 纹理坐标,映射到贴图的不同区域 |
_row / _col / _layer |
3 bytes | 魔方坐标系中的逻辑位置,用于判断归属面和重建正确位置 |
_rotateMatrix |
16 floats | 4x4 列主序矩阵,累积该方块已受的所有旋转变换 |
_colors |
104 bytes | 每 4 byte 对应一个顶点的 RGBA 颜色,用于像素拾取 |
关键设计:
_row、_col、_layer是"逻辑坐标",旋转不改变;_rotateMatrix是"视觉变换",在渲染时通过矩阵乘法改变显示位置。两者分离使得"旋转后面上方块交换行列值"和"每个方块记住自己累积的旋转"成为独立操作。
5. 游戏核心逻辑 — GameLogic
GameLogic 是一个只有类方法的纯逻辑类,负责魔方的所有状态管理。
5.1 初始化:顶点与纹理
使用静态常量数组 cubeVerticesStrip[] 定义一个小方块的 26 个顶点(GL_TRIANGLE_STRIP 拓扑)。根据 (row, col, layer) 计算偏移:
+ (void)initVecteAndTextCoord:(Cube *)cube :(GLint)row :(GLint)col :(GLint)layer
{
GLfloat moveX = 2.0 * (col - 1);
GLfloat moveY = -2.0 * (row - 1);
GLfloat moveZ = -2.0 * (layer - 1);
// 从模板顶点数组复制并偏移
for (int i = 0, j = 0; i < 78; i += 3) {
cube->_vertices[i] = cubeVerticesStrip[j++] + moveX;
cube->_vertices[i+1] = cubeVerticesStrip[j++] + moveY;
cube->_vertices[i+2] = cubeVerticesStrip[j++] + moveZ;
}
// 根据位置选择纹理坐标模板
int index = row * 3 + col + layer * 9;
for (int i = 0; i < 52; i++)
cube->_textureCoords[i] = textureCoords[index][i];
// 为选择检测着色(每个面一个唯一色号)
cube->_colors[0] = cube->_colors[4] = ... = colorFlag++; // 正面
cube->_colors[16] = cube->_colors[20] = ... = colorFlag++; // 右面
// ... 共6个面递增着色
// 初始化为单位矩阵
[MatrixTools applyIdentity:cube->_rotateMatrix];
}
5.2 颜色清除(内部面隐藏)
魔方内部面不可见,不应被选择。将它们的选择颜色设为 0(黑色),就能在像素拾取时过滤掉:
+ (void)clearColor:(Cube *)cubes
{
for (int i = 0; i < 27; i++) {
if (cubes[i]._layer != 0) // 不在最外层,正面不可见
/* 清除front face颜色 */
if (cubes[i]._layer != 2) // 不在最内层,背面不可见
/* 清除back face颜色 */
if (cubes[i]._row != 2) // 下面不可见
/* ... */
// ... 同理处理 row/col 的上下左右四个面
}
}
5.3 旋转方向判断 — checkRotationState:
当用户从一个方块滑动到另一个方块时,需要根据两个方块的 行列层坐标 和 触摸面 来判断旋转轴和方向。函数同时输出 rotationState 和 currentSlice[3]。
- Y 轴旋转: 两个方块 row 相同 + 在同一个 layer 上滑动,或两个触碰面形成环绕关系(FRONT→RIGHT→BACK→LEFT)
- X 轴旋转: 两个方块 col 相同 + 沿 layer 方向滑动,或两个面形成竖直环绕(TOP→FRONT→BOTTOM→BACK)
- Z 轴旋转: 两个方块 layer 相同 + 沿 row 方向滑动,或跨面滑动(TOP→RIGHT→BOTTOM→LEFT)
5.4 Slice 旋转 — sliceRotateWith:
旋转完成后,交换被旋转面上方块的 行列层坐标 和 选择颜色。为每种旋转状态预定义了"交换序列"(squence 数组):
// 每8个数一组:{cubeIdx1, face1, cubeIdx2, face2, cubeIdx3, face3, cubeIdx4, face4}
// 含义:循环交换四个位置的颜色和坐标
GLint squence[56] = {
1, 5, 3, 3, 7, 4, 5, 1, // 角块交换
0, 3, 6, 4, 8, 1, 2, 5, // 棱块交换
// ... 共7组,覆盖4角块+3棱块
};
5.5 完成检测
+ (Boolean)isOK:(Cube*)cubes
{
for (int i = 0; i < 27; i++) {
if (cubes[i]._row * 3 + cubes[i]._col + cubes[i]._layer * 9 != i)
return NO;
}
return YES;
}
初始时方块 i 的位置满足 row*3+col+layer*9 = i,只需检查每个方块的当前位置是否等于其初始索引。
6. 矩阵运算引擎 — MatrixTools
不依赖 GLKit 或任何第三方库,纯 C 语言实现了完整的 4x4 列主序矩阵运算:
@interface MatrixTools : UIViewController
+ (void)applyIdentity:(GLfloat *)m;
+ (void)multiplyMatrix:(GLfloat *)m1 by:(GLfloat *)m2 giving:(GLfloat *)m3;
+ (void)applyTranslation:(GLfloat *)m x:(GLfloat)x y:(GLfloat)y z:(GLfloat)z;
+ (void)applyScale:(GLfloat *)m x:(GLfloat)x y:(GLfloat)y z:(GLfloat)z;
+ (void)applyRotation:(GLfloat *)m x:(GLfloat)x y:(GLfloat)y z:(GLfloat)z;
+ (void)applyProjection:(GLfloat *)m fov:(GLfloat)fov
aspect:(GLfloat)aspect near:(GLfloat)near far:(GLfloat)far;
@end
透视投影矩阵实现
+ (void)applyProjection:(GLfloat *)m fov:(GLfloat)fov ...
{
GLfloat r = fov * M_PI / 180.0f;
GLfloat f = 1.0f / tanf(r / 2.0f);
m[0] = f;
m[5] = f / aspect;
m[10] = -((far + near) / (far - near));
m[11] = -1;
m[14] = -(2 * far * near / (far - near));
m[15] = 0;
}
别名安全:
multiplyMatrix:by:giving:使用临时矩阵存储结果后再 copy 到 m3,因为调用方经常 m3 == m2(例如multiplyMatrix:scale by:mvp giving:mvp),直接写入会破坏源数据。
7. OpenGL 渲染引擎
7.1 EAGLView — OpenGL 画布
覆盖 +layerClass 返回 CAEAGLLayer,使 OpenGL 渲染内容能显示在 UIKit 界面上。管理三样核心 GL 资源:defaultFramebuffer、colorRenderbuffer(连接到 CAEAGLLayer)、depthRenderbuffer(GL_DEPTH_COMPONENT16)。
7.2 YHCOpenGLProgram — Shader Program 封装
完整的 Shader 编译→链接→Uniform 查找流程封装:
.vsh 文件 .fsh 文件
(Vertex Shader) (Fragment Shader)
| |
v v
glCreateShader() glCreateShader()
| |
v v
compileShader: compileShader:
GL_VERTEX_SHADER GL_FRAGMENT_SHADER
| |
+------------+-----------+
v
glCreateProgram()
glAttachShader() x 2
glBindAttribLocation() (链接前绑定)
glLinkProgram()
glGetUniformLocation() (链接后获取)
glDetachShader() + glDeleteShader()
7.3 主渲染 Shader
Vertex Shader (Shader.vsh):
attribute vec4 position;
attribute vec4 texture_coord;
varying vec2 texCoordVarying;
uniform mat4 mvp_matrix;
void main() {
gl_Position = mvp_matrix * position;
texCoordVarying = texture_coord.st;
}
Fragment Shader (Shader.fsh):
varying highp vec2 texCoordVarying;
uniform sampler2D texture;
void main() {
gl_FragColor = texture2D(texture, texCoordVarying);
}
这是典型的纹理贴图管线:Vertex Shader 只做 MVP 变换并传递纹理坐标,Fragment Shader 直接从贴图采样。
7.4 选择检测 Shader
SelectShader: 纯色渲染,不采样纹理。Vertex Shader 直接传递 color attribute 给 Fragment Shader 输出:
// Vertex Shader
void main() {
gl_Position = mvp_matrix * position;
colorVarying = color;
}
// Fragment Shader: gl_FragColor = colorVarying;
7.5 YHCOpenGLTools — 纹理加载
将 UIImage 转换为 OpenGL 纹理,核心流程:CGBitmapContext 获取像素数据 → 翻转 Y 轴(CG 与 GL 坐标系差异)→ glTexImage2D 上传 → glGenerateMipmap。
8. 核心控制器 — ViewController
约 620 行代码,是整个应用的核心,负责渲染循环、触摸事件、像素拾取、状态管理、UI 交互。
8.1 变换矩阵栈
| 矩阵 | 作用 | 用户可操作? |
|---|---|---|
_scaleMatrix |
缩放(当前始终为单位矩阵) | 否 |
_rotationMatrix |
整体旋转(两指滑动) | 是 |
_translationMatrix |
平移(Z轴缩放,双指捏合) | 是 |
_projectionMatrix |
透视投影(45度 FOV) | 否 |
_sliceRotationMatrix |
面旋转动画(临时) | 否 |
8.2 渲染循环 — drawFrame
使用 CADisplayLink 驱动(60fps),核心流程:
- (void)drawFrame
{
int rotationAngle = 10; // 用户操作:每帧10度
// 1. 随机打乱:每帧随机旋转一步(共10步)
if (randcount < 0) {
int i = arc4random() % 3; // 随机选轴
int j = arc4random() % 3; // 随机选层
_rotationState = ...;
_currentSlice[i] = j;
randcount++;
rotationAngle = 90; // 打乱时直接90度
}
// 2. 计算旋转增量
if (顺时针) _sliceRotateAngle += rotationAngle;
else if (逆时针) _sliceRotateAngle -= rotationAngle;
for (int i = 0; i < 27; i++) {
// 3. 应用方块累积旋转
[MatrixTools multiplyMatrix:cubes[i]._rotateMatrix
by:mvpMatrix giving:mvpMatrix];
// 4. 应用slice旋转(如果在旋转面上)
if (cubes[i]._layer == _currentSlice[2]) {
[MatrixTools applyRotation:_sliceRotationMatrix
x:0 y:0 z:_sliceRotateAngle];
...
if (fabs(_sliceRotateAngle) >= 90) {
// 5. 固化旋转到方块矩阵 + 更新GameLogic数据
[GameLogic sliceRotateWith:...];
_rotationState = ROTATE_NONE;
}
}
// 6. 应用全局变换链
// mvp = projection x translation x scale x rotation
// 7. 绘制26个顶点 (GL_TRIANGLE_STRIP)
glDrawArrays(GL_TRIANGLE_STRIP, 0, 26);
}
}
动画机制: 旋转动画不是通过 CAAnimation,而是在 drawFrame 中逐帧递增 _sliceRotateAngle。用户操作每帧 10度(9帧完成90度),打乱时直接 90度。旋转完成后固化到方块矩阵并更新逻辑状态。
8.3 状态管理
// 状态机
_rotationState = ROTATE_NONE; // -1: 空闲, 0: 整体旋转
// 1/2: X轴顺/逆, 3/4: Y轴顺/逆, 5/6: Z轴顺/逆
_currentSlice[3] = {-1, -1, -1}; // (row, col, layer) -1表示不旋转该维
_sliceRotateAngle = 0; // 动画进度 (0~90)
_isSelectMode = false; // 是否像素拾取模式
_isCheck = true; // 是否需要在touchMoved中做选择
9. 文字渲染子系统
项目实现了一套完整的位图字体 OpenGL 文字渲染系统:
| 组件 | 功能 |
|---|---|
| YHCFontImporter | 解析位图字体图片,自动检测 92 个 ASCII 字符的边界并建立字符→纹理坐标映射表 |
| YHCTextString | 单条文字对象,维护位置、颜色、透明度、生命周期(支持淡入淡出),根据字符串动态生成顶点数据 |
| YHCTextStringManager | 管理多条文字的生命周期,批量渲染。独立 Shader Program,支持颜色叠加和 alpha 控制 |
Fragment Shader 使用颜色调制技术:
gl_FragColor = texture2D(texture, texCoordVarying)
* modulateColor * alpha;
// 白色位图字体 x 任意颜色 x 透明度 → 灵活的文字风格
10. 触摸交互与像素拾取(Pixel Picking)
这是项目中最精妙的设计。单指滑动的面选择没有使用复杂的射线检测,而是用 离屏 Framebuffer + 颜色编码 + glReadPixels 实现了精确的像素拾取。
10.1 原理
初始化时为每个 Cube 的每个面赋予唯一的选择颜色编码(通过 colorFlag 递增)。触摸发生时使用纯色 Shader 离屏渲染一帧,然后 glReadPixels 读取触摸点的像素颜色,通过颜色反查所属方块和面。
10.2 完整流程
- (void)selectSlice:(CGPoint)point1 :(CGPoint)point2
{
// Step 1: 创建离屏 Framebuffer
glGenFramebuffers(1, &framebuffer);
glGenRenderbuffers(1, &colorRenderbuffer);
glRenderbufferStorage(..., GL_RGBA8_OES, width, height);
// Step 2: 切换到SelectShader(纯色渲染)
_isSelectMode = true;
// Step 3: 离屏渲染一帧
[self drawFrame];
// Step 4: 读取触摸点的像素颜色
GLubyte pixelColor[4];
glReadPixels(point1.x, viewport[3] - point1.y,
1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixelColor);
// Step 5: 反查颜色 → 方块 → 面
for (int i = 0; i < 27; i++) {
for (int j = 0; j < 80; j += 16) {
if (cubes[i]._colors[j] == pixelColor[0]) {
cube = &cubes[i];
face1 = (pixelColor[0] - 1) % 6;
break;
}
}
}
// Step 6: 沿滑动方向搜索第二个方块
do {
[GameLogic getNextPoint:point1 point2:point2
nextPoint:&nextPoint inc:inc flag:flag];
glReadPixels(nextPoint.x, ..., tempPixelColor);
if (pixelColor[0] != tempPixelColor[0]) {
// 找到不同方块 → 判断旋转方向
[GameLogic checkRotationState:&_rotationState
currentSlice:_currentSlice
cube1:cube face1:face1
cube2:&cubes[i] face2:face2
flag:flag];
}
} while (_rotationState == ROTATE_NONE);
// Step 7: 清理离屏资源
glDeleteRenderbuffers(...);
}
10.3 搜索步进算法
根据滑动方向逐步延伸搜索点。如果搜索到屏幕外(背景色),自动反向搜索:
// 根据 deltaX / deltaY 比例决定搜索方向
if (deltaX > deltaY)
nextPoint = CGPointMake(point2.x + inc,
point2.y + inc * (deltaY / deltaX));
else
nextPoint = CGPointMake(point2.x + inc * (deltaX / deltaY),
point2.y + inc);
10.4 触摸事件流程
touchesBegan:
单指: 记录起始点, 重置状态, _isCheck = true
双指: 记录初始 pinch 距离
touchesMoved:
单指: if (_isCheck) → selectSlice(pixel picking + 方向判断)
同时: rotateCubeAroundX:andY: (整体旋转)
双指: 更新缩放距离
touchesEnded:
检查是否完成
11. 设计亮点与技术总结
11.1 架构亮点
| 亮点 | 说明 |
|---|---|
| 渲染-逻辑分离 | ViewController 只管渲染和交互,GameLogic 只管魔方状态。两者通过静态 Cube 数组和状态变量通信,职责清晰 |
| 像素拾取代替射线检测 | 离屏 Framebuffer + 颜色编码 + glReadPixels 实现面选择,避免了复杂的 3D 碰撞检测 |
| 手写矩阵库 | 不依赖 GLKit 或任何第三方库,纯 C 语言实现 4x4 矩阵所有操作,适合学习 3D 数学基础 |
| 自研文字系统 | 从位图字体自动解析字符边界,完整 OpenGL 文字渲染管线,支持颜色、透明度、生命周期 |
| 帧动画替代 CAAnimation | 面的旋转通过 CADisplayLink 逐帧更新角度,动画完全可控,避免 Core Animation 与 OpenGL 混用的复杂性 |
| 逻辑-视觉双重坐标系 |
_row/_col/_layer 跟踪逻辑位置,_rotateMatrix 累积视觉变换。分离设计使得旋转后的数据更新逻辑简洁明了 |
11.2 技术栈总结
| 层面 | 技术 |
|---|---|
| 渲染底层 | OpenGL ES 2.0, GLSL |
| 渲染循环 | CADisplayLink (60fps) |
| 画布 | CAEAGLLayer + Framebuffer |
| 纹理绘制 | GL_TRIANGLE_STRIP |
| 触摸交互 | 手动 touchesBegan/Moved/Ended |
| 像素拾取 | 离屏 Framebuffer + glReadPixels |
| 矩阵运算 | 自研 4x4 矩阵库 (列主序) |
| 文字渲染 | 位图字体 + 独立 Shader |
| 内存管理 | MRC |
11.3 改进空间
- ARC 迁移: 从 MRC 迁移到 ARC,消除所有 retain/release/dealloc 调用
- SceneKit / Metal: 使用现代渲染框架替代手写 OpenGL ES
-
每帧编译 Shader:
drawFrame在非选择模式下每帧都重新创建和编译 Shader Program,极大浪费 GPU 资源,应在初始化时编译一次
11.4 关键文件清单
| 文件 | 行数 | 核心内容 |
|---|---|---|
ViewController.m |
~620 | 渲染循环、触摸处理、像素拾取、UI 交互 — 应用的心脏 |
GameLogic.m |
~440 | 魔方初始化、颜色管理、旋转判断、Slice 更新、完成检测 |
MatrixTools.m |
~196 | 纯 C 语言 4x4 矩阵运算 |
YHCOpenGLProgram.m |
~235 | Shader 编译链接封装 |
EAGLView.m |
~155 | CAEAGLLayer + Framebuffer 管理 |
Cube.h |
~38 | 核心数据结构定义 |
结束语
MagicCube 是一个"小而美"的 iOS OpenGL ES 学习项目。它在不到 3000 行代码中,完成了一个功能完整的 3D 魔方游戏。从手写矩阵库到像素拾取,从 Shader 封装到位图字体渲染,每一个模块都体现了早期 iOS 开发者在资源受限的环境下对性能的极致追求和对底层原理的深刻理解。
虽然技术栈已显老旧,但其架构设计理念 — 数据驱动渲染、逻辑视觉分离、帧动画替代 UI 动画 — 在今天的 Metal 和 SceneKit 时代依然有着重要的参考价值。