MagicCube 源码深度解读

基于 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:

当用户从一个方块滑动到另一个方块时,需要根据两个方块的 行列层坐标触摸面 来判断旋转轴和方向。函数同时输出 rotationStatecurrentSlice[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 时代依然有着重要的参考价值。


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

  • 今天是2025年08月03日,农历闰六月初十,星期天。 先简单梳理一下已经过去的昨天。 ①英语:背诵新单词15个,...
    还有力气追逐梦想吗阅读 55评论 0 2
  • 2025-08-3,周日,晴、高温 气温29°—38°,体感温度35°,露点温度26°,南风2级,阵风风速20公里...
    秋韵儿阅读 47评论 0 1
  • 京心❤️达三店:何海港 2025年8月2日 落地真经 严格就是爱,放纵既是害 正能量语录 每一颗螺丝都有标准 每一...
    京心达何海港阅读 52评论 0 0
  • 这个杨景媛,究竟是一只什么鸟? 自从2023年7月武大图书馆性骚扰事件发生以来,当事人双方的前途命运,简直是一个在...
    吟啸斋主阅读 54评论 0 0
  • 2025年8月3号 不要杞人忧天,烦恼并不会减少明天的负担,却会失去今天的快乐!
    开心红红阅读 104评论 0 0

友情链接更多精彩内容