『OpenGL学习滤镜相机』- Day2: 渲染第一个三角形

351 阅读6分钟

前言: 『OpenGL学习』 从零打造 Android 滤镜相机

上一篇: # 『OpenGL学习滤镜相机』- Day1: OpenGL ES 入门与环境搭建

Github: OpenGLTest

📚 今日目标

  • 理解 OpenGL 渲染管线的工作流程
  • 掌握顶点数据的创建和传递
  • 学习着色器(Shader)的基本概念
  • 编写第一个顶点着色器和片段着色器
  • 成功渲染一个彩色三角形

运行效果:

screenshot2025-11-04 10:34:46.png

🎯 学习内容

1. OpenGL 渲染管线

渲染管线流程图

┌────────────────┐
│  顶点数据      │  ← 定义形状的顶点坐标
└───────┬────────┘
        ↓
┌────────────────┐
│  顶点着色器    │  ← 处理每个顶点(位置变换等)
└───────┬────────┘
        ↓
┌────────────────┐
│  图元装配      │  ← 将顶点组装成三角形
└───────┬────────┘
        ↓
┌────────────────┐
│  光栅化        │  ← 将三角形转换为片段(像素)
└───────┬────────┘
        ↓
┌────────────────┐
│  片段着色器    │  ← 计算每个片段(像素)的颜色
└───────┬────────┘
        ↓
┌────────────────┐
│  帧缓冲        │  ← 最终显示到屏幕
└────────────────┘

核心概念

  • 顶点(Vertex):定义形状的点,包含位置、颜色等信息
  • 图元(Primitive):基本图形单元,如点、线、三角形
  • 片段(Fragment):光栅化后的像素候选,包含位置、颜色等信息
  • 着色器(Shader):运行在 GPU 上的小程序

2. 着色器(Shader)

什么是着色器?

着色器是用 GLSL(OpenGL Shading Language) 编写的程序,运行在 GPU 上,用于控制渲染管线的某些阶段。

OpenGL ES 2.0 的两种着色器

着色器类型作用输入输出
顶点着色器 (Vertex Shader)处理每个顶点顶点属性顶点位置、varying 变量
片段着色器 (Fragment Shader)计算每个片段的颜色varying 变量片段颜色

顶点着色器示例

// 最简单的顶点着色器
attribute vec4 aPosition;  // 输入:顶点位置
void main() {
    gl_Position = aPosition;  // 输出:顶点位置
}

片段着色器示例

// 最简单的片段着色器
precision mediump float;  // 精度声明
void main() {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);  // 输出:红色
}

3. GLSL 基础语法

变量类型

修饰符说明使用场景
attribute顶点属性,每个顶点不同仅顶点着色器,接收顶点数据
uniform统一变量,所有顶点/片段相同两种着色器都可用,传递常量
varying易变变量,从顶点传到片段顶点着色器输出→片段着色器输入

数据类型

类型说明示例
float浮点数float a = 1.0;
vec22D 向量vec2 pos = vec2(0.0, 1.0);
vec33D 向量vec3 color = vec3(1.0, 0.0, 0.0);
vec44D 向量vec4 rgba = vec4(1.0, 0.0, 0.0, 1.0);
mat44x4 矩阵mat4 matrix;

4. 顶点数据

定义三角形的顶点

在 OpenGL 的标准化坐标系中(NDC),我们定义一个三角形:

val vertices = floatArrayOf(
    // x,    y,    z
     0.0f,  0.5f, 0.0f,  // 顶点 0:上方
    -0.5f, -0.5f, 0.0f,  // 顶点 1:左下
     0.5f, -0.5f, 0.0f   // 顶点 2:右下
)

顶点缓冲对象(VBO)

OpenGL 不能直接使用 Java/Kotlin 数组,需要转换为 ByteBuffer

val vertexBuffer = ByteBuffer.allocateDirect(vertices.size * 4)
    .order(ByteOrder.nativeOrder())  // 使用本地字节序
    .asFloatBuffer()                  // 转换为 Float 缓冲
    .put(vertices)                    // 填充数据
    .position(0)                      // 重置位置

为什么要这样做?

  • Java 数组在堆内存,可能被 GC 移动
  • ByteBuffer 使用直接内存,OpenGL 可以直接访问
  • 字节序问题:不同平台可能不同

5. 着色器程序的编译和链接

完整流程

┌──────────────┐      ┌──────────────┐
│ 顶点着色器   │      │ 片段着色器   │
│ 源代码       │      │ 源代码       │
└──────┬───────┘      └──────┬───────┘
       │                     │
       ↓ 编译                ↓ 编译
┌──────────────┐      ┌──────────────┐
│ 顶点着色器   │      │ 片段着色器   │
│ 对象         │      │ 对象         │
└──────┬───────┘      └──────┬───────┘
       │                     │
       └──────────┬──────────┘
                  ↓ 链接
           ┌─────────────┐
           │ 着色器程序  │
           └─────────────┘

代码步骤

// 1. 创建着色器对象
val vertexShader = GLES20.glCreateShader(GLES20.GL_VERTEX_SHADER)

// 2. 上传着色器源代码
GLES20.glShaderSource(vertexShader, vertexShaderCode)

// 3. 编译着色器
GLES20.glCompileShader(vertexShader)

// 4. 检查编译状态
val status = IntArray(1)
GLES20.glGetShaderiv(vertexShader, GLES20.GL_COMPILE_STATUS, status, 0)
if (status[0] == 0) {
    // 编译失败,获取错误信息
    val log = GLES20.glGetShaderInfoLog(vertexShader)
    throw RuntimeException("Shader compilation failed: $log")
}

// 片段着色器同理...

// 5. 创建程序对象
val program = GLES20.glCreateProgram()

// 6. 附加着色器
GLES20.glAttachShader(program, vertexShader)
GLES20.glAttachShader(program, fragmentShader)

// 7. 链接程序
GLES20.glLinkProgram(program)

// 8. 检查链接状态(类似编译检查)

💻 代码实践

Day02Renderer 核心代码

class Day02Renderer : GLSurfaceView.Renderer {

    // 着色器源代码
    private val vertexShaderCode = """
        attribute vec4 aPosition;
        void main() {
            gl_Position = aPosition;
        }
    """.trimIndent()

    private val fragmentShaderCode = """
        precision mediump float;
        void main() {
            gl_FragColor = vec4(1.0, 0.5, 0.2, 1.0);
        }
    """.trimIndent()

    // 三角形顶点
    private val vertices = floatArrayOf(
         0.0f,  0.5f, 0.0f,
        -0.5f, -0.5f, 0.0f,
         0.5f, -0.5f, 0.0f
    )

    private lateinit var vertexBuffer: FloatBuffer
    private var program: Int = 0
    private var aPositionLocation: Int = 0

    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        // 设置背景色
        GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f)

        // 创建顶点缓冲
        vertexBuffer = ByteBuffer.allocateDirect(vertices.size * 4)
            .order(ByteOrder.nativeOrder())
            .asFloatBuffer()
            .put(vertices)
        vertexBuffer.position(0)

        // 编译着色器
        val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCode)
        val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentShaderCode)

        // 创建程序
        program = GLES20.glCreateProgram()
        GLES20.glAttachShader(program, vertexShader)
        GLES20.glAttachShader(program, fragmentShader)
        GLES20.glLinkProgram(program)

        // 获取 attribute 位置
        aPositionLocation = GLES20.glGetAttribLocation(program, "aPosition")
    }

    override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
        GLES20.glViewport(0, 0, width, height)
    }

    override fun onDrawFrame(gl: GL10?) {
        // 清屏
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)

        // 使用程序
        GLES20.glUseProgram(program)

        // 启用顶点属性
        GLES20.glEnableVertexAttribArray(aPositionLocation)

        // 传递顶点数据
        GLES20.glVertexAttribPointer(
            aPositionLocation,  // 属性位置
            3,                  // 每个顶点的分量数(x, y, z)
            GLES20.GL_FLOAT,    // 数据类型
            false,              // 是否归一化
            0,                  // 步长(0 表示紧密排列)
            vertexBuffer        // 数据缓冲
        )

        // 绘制三角形
        GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3)

        // 禁用顶点属性
        GLES20.glDisableVertexAttribArray(aPositionLocation)
    }

    private fun loadShader(type: Int, shaderCode: String): Int {
        val shader = GLES20.glCreateShader(type)
        GLES20.glShaderSource(shader, shaderCode)
        GLES20.glCompileShader(shader)
        return shader
    }
}

🎨 练习任务

基础任务

  1. 运行代码,看到橙色三角形

    • 在 MainActivity 点击"Day 02"
    • 确认能看到一个橙色三角形
  2. 修改三角形颜色

    • 在片段着色器中修改 gl_FragColor 的值
    • 尝试:红色 (1, 0, 0, 1)、绿色 (0, 1, 0, 1)、蓝色 (0, 0, 1, 1)
  3. 修改三角形形状

    • 修改顶点坐标,改变三角形的大小和位置
    • 尝试创建不同形状的三角形

进阶任务

  1. 渲染多个三角形

    • 增加顶点数据,绘制两个三角形
    • 提示:6 个顶点,每 3 个组成一个三角形
  2. 渐变色三角形

    • 为每个顶点添加颜色属性
    • 使用 varying 变量传递颜色
    • 实现顶点颜色插值
  3. 绘制正方形

    • 使用两个三角形拼接成正方形
    • 思考:为什么需要 6 个顶点?

📖 重要概念总结

OpenGL ES 核心 API(新学的)

API说明
glCreateShader(type)创建着色器对象
glShaderSource(shader, source)上传着色器源代码
glCompileShader(shader)编译着色器
glCreateProgram()创建程序对象
glAttachShader(program, shader)附加着色器到程序
glLinkProgram(program)链接程序
glUseProgram(program)使用程序
glGetAttribLocation(program, name)获取 attribute 位置
glEnableVertexAttribArray(location)启用顶点属性数组
glVertexAttribPointer(...)指定顶点属性数据
glDrawArrays(mode, first, count)绘制图元

关键术语

  • Vertex Shader:顶点着色器
  • Fragment Shader:片段着色器
  • GLSL:OpenGL 着色语言
  • Attribute:顶点属性
  • Uniform:统一变量
  • Varying:易变变量
  • VBO:顶点缓冲对象

❓ 常见问题

Q1: 为什么看不到三角形?

检查清单

  • 着色器编译链接成功?
  • 调用了 glUseProgram(program)
  • 顶点坐标在 [-1, 1] 范围内?
  • 调用了 glEnableVertexAttribArray
  • Alpha 值设置为 1.0?

Q2: 三角形方向反了怎么办?

OpenGL 默认使用逆时针顺序定义正面。调整顶点顺序即可。

Q3: ByteBuffer 为什么要 allocateDirect?

  • allocate():在 JVM 堆上分配,会被 GC 管理
  • allocateDirect():在本地内存分配,OpenGL 可直接访问

Q4: glVertexAttribPointer 的参数是什么意思?

glVertexAttribPointer(
    location,      // attribute 的位置
    size,          // 每个顶点几个分量(2/3/4)
    type,          // 数据类型(GL_FLOAT等)
    normalized,    // 是否归一化
    stride,        // 步长(字节)
    buffer         // 数据缓冲
)

🔗 扩展阅读

✅ 今日小结

今天我们:

  1. ✅ 理解了 OpenGL 渲染管线的流程
  2. ✅ 学习了着色器的基本概念和 GLSL 语法
  3. ✅ 掌握了顶点数据的创建和传递
  4. ✅ 编写了第一个着色器程序
  5. ✅ 成功渲染了一个三角形

下一篇: # 『OpenGL学习滤镜相机』- Day3: 着色器基础 - GLSL 语言