第 17 集是 OpenGL 系列中视觉效果实现飞跃的一集。在此之前,你只能画出单色的三角形;学完纹理后,你就能把图片贴在你的几何图形上。
即便你现在还不学习具体的 API,了解其背后的“映射逻辑”对你理解未来的 3D 渲染至关重要。
简述#
纹理的基本原理:坐标映射#
核心概念:纹理坐标(UV 坐标)。
UV 坐标系:
- 它是归一化的, 和 的范围都是 到 。
- 左下角是 ,右上角是 。
映射逻辑:你在顶点数据中多加两个 float(),告诉 GPU:“这个顶点对应图片上的哪个点”。GPU 会自动在顶点之间进行插值,算出中间每个像素该涂什么颜色。
图像加载与 stb_image#
痛点:OpenGL 本身不认识
.png或.jpg这种压缩格式。解决方案:引入一个极简的开源库
stb_image。- 这是一个单头文件库,非常符合 Cherno 的极简风格。
- 它的作用是将磁盘上的图片文件解码成内存中的 RGBA 字节数组(即一串
unsigned char)。
OpenGL 纹理对象 (Texture Object)#
创建与绑定:
- 类似于
VertexBuffer,你需要glGenTextures生成一个 ID,然后glBindTexture。
- 类似于
关键参数设置 (Parameters):
- 过滤 (Filtering):当图片被放大或缩小时,像素该怎么补全?(
GL_LINEAR线性平滑,或GL_NEAREST像素风)。 - 包装 (Wrapping):当 UV 坐标超过 时,图片是重复(Repeat)还是拉伸(Clamp)?
- 过滤 (Filtering):当图片被放大或缩小时,像素该怎么补全?(
数据上传:使用
glTexImage2D将 CPU 内存中的像素数组发送到 GPU 显存。
纹理插槽 (Texture Slots)#
- 多纹理支持:现代显卡可以同时绑定多个纹理(通常是 16 个或 32 个槽位)。
- Slot 0:默认情况下,我们使用 0 号槽位。
- Uniform 关联:在 Shader 中,纹理被表示为
sampler2D类型。你需要通过glUniform1i告诉 Shader:“请去 0 号槽位拿图片数据”。
💡 核心技术要点总结#
- 数据的扩展:你的
VertexBufferLayout现在需要Push<float>(2)来增加纹理坐标属性。 - 坐标翻转 (Flip):OpenGL 默认图片左下角为 ,但大多数图片文件存储是从左上角开始的。Cherno 会教你一行代码
stbi_set_flip_vertically_on_load(1);来解决图像倒置的问题。图像读取是整行整行的读取,原本是先读 左上-右上,然后左下-右下。现在变成了左下-右下,然后左上-右上OpenGL像素生成:先水平往右走(填充底部的第一行:左下 右下)。换行,往上挪一个像素。再水平往右走(填充第二行)。最后到达数组末尾,对应纹理坐标的 ,即右上角。当你设置了 flip_vertically_on_load(1),stb_image 在解码完成后(或过程中),会启动一个内存交换逻辑:它会开辟一个临时的行缓冲区。把 第 1 行 和 最后 1 行 的数据在内存地址上进行对调。把 第 2 行 和 倒数第 2 行 对调。以此类推,直到中心。
- 抽象化:Cherno 依然会遵循之前的风格,创建一个
Texture类来封装这些繁琐的初始化过程。
⚠️ 预热建议(你现在需要知道的)#
- 纹理不是“颜色”:它是一块内存数据。在 Shader 里,你可以拿这块数据当颜色用,也可以拿它当“高度图”或者“法线图”来改变物体的凹凸感。
- 文件路径:从这一集开始,路径问题会成为 Bug 的高发区。确保你的图片放在项目能找到的地方。
代码#
Texture#
#pragma once
#include <string>
class Texture
{
private:
unsigned int m_RendererID;
std::string m_FilePath;
unsigned char* m_LocalBuffer;
//BPP:它告诉 OpenGL 每一个像素到底由多少个数字组成。如果是 4,OpenGL 就知道按 [R, G, B, A] 的格式去解析数据
int m_Width, m_Height, m_BPP; //BPP:每像素位数
public:
Texture(const std::string& path);
~Texture();
//想要绑定纹理的插槽
//一次性可以绑定多个纹理,Windows上可能有32个,取决于显卡
void Bind(unsigned int slot = 0) const;
void Unbind() const;
inline int GetWidth() const { return m_Width; }
inline int GetHeight() const { return m_Height; }
};#include "Texture.h"
#include "Renderer.h"
#include <iostream>
#include "vendor/stb_image/stb_image.h"
Texture::Texture(const std::string& path)
:m_RendererID(0), m_FilePath(path), m_LocalBuffer(nullptr),
m_Width(0), m_Height(0), m_BPP(0)
{
stbi_set_flip_vertically_on_load(1);
//BPP (Bits Per Pixel)。这里它会告诉你图片原本有多少个通道(比如 JPG 是 3 个,带透明的 PNG 是 4 个)
//4 强制通道数 最关键的参数。你告诉 stbi:“不管原图是啥样,请通通给我转成 RGBA(4 通道)格式”。
m_LocalBuffer = stbi_load(path.c_str(), &m_Width, &m_Height, &m_BPP, 4);
//生成一个缓冲区并绑定到某个id
glGenTextures(1, &m_RendererID);
//绑定到这个缓冲区id
glBindTexture(GL_TEXTURE_2D, m_RendererID);
//设置如何处理纹理边界
//GL_LINEAR:查看目标像素周围的 4 个像素,取它们的加权平均值
//GL_NEAREST:只取离目标像素最近的那个像素的值
//GL_TEXTURE_MIN_FILTER:当纹理被缩小的时候,OpenGL 如何采样纹理
//GL_TEXTURE_MAG_FILTER:当纹理被放大时,OpenGL 如何采样纹理
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
//当你的纹理坐标(UV 坐标)超出了 0.0 ~ 1.0 这个“标准范围”时,GPU 应该如何处理那些“多出来”的区域
//GL_CLAMP:当纹理坐标超出标准范围时,GPU 会将纹理坐标限制在 0.0 ~ 1.0 的范围内,并使用边界像素的颜色进行填充。这意味着如果纹理坐标超出范围,GPU 会==重复使用边界像素==的颜色来填充超出部分。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
//把这里读到的数据给opengl
//参数1:GL_TEXTURE_2D:目标类型。告诉 OpenGL 你现在要填充的是一张普通的 2D 纹理。
//参数2:0:Mipmap 层级(Level)。0 代表原始分辨率(最高级)。如果你以后手动生成多级渐远纹理,这里会用到 1, 2, 3 等。
//参数3:GL_RGBA8:内部格式(Internal Format)。告诉 OpenGL 你想要在 GPU 内部以什么格式存储这张纹理。GL_RGBA8 表示每个像素使用 4 个字节(32 位)来存储 RGBA 数据。
//参数4:m_Width:纹理的宽度,以像素为单位。
//参数5:m_Height:纹理的高度,以像素为单位。
//参数6:0:边框 (Border)。这是一个遗留参数,在现代 OpenGL 中必须传 0。
//参数7:GL_RGBA:数据格式。告诉 OpenGL 你提供的数据是 RGBA 格式。
//参数8:GL_UNSIGNED_BYTE:数据类型。告诉 OpenGL 你提供的数据是无符号字节类型。
//参数9:m_LocalBuffer:实际的像素数据。
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, m_Width, m_Height, 0, GL_RGBA, GL_UNSIGNED_BYTE, m_LocalBuffer);
//传给GPU数据后,解绑Texture
glBindTexture(GL_TEXTURE_2D, 0);
if (m_LocalBuffer)
{
//stbi_image_free 本质上是一个包装过的 free() 函数。
stbi_image_free(m_LocalBuffer);
}
else
{
std::cout << "Error: Could not load texture at " << path << std::endl;
}
}
Texture::~Texture()
{
//通知GPU销毁指定的纹理资源,并回收那块显存。
glDeleteTextures(1, &m_RendererID);
}
void Texture::Bind(unsigned int slot /*= 0*/) const
{
//激活纹理单元
//激活纹理单元后,后续的 glBindTexture 调用会将该纹理绑定到当前激活的纹理单元。纹理单元 GL\_TEXTURE0 默认始终处于激活状态
//注:.shader 中的 uniform sampler2D 纹理采样器默认绑定在纹理单元 0 上,所以我们在这里默认绑定到 slot 0 上。这里可以指定某个,这样.shader中就可以去对应的slot上取值了
glActiveTexture(GL_TEXTURE0 + slot);
//绑定到这个缓冲区id
glBindTexture(GL_TEXTURE_2D, m_RendererID);
}
void Texture::Unbind() const
{
glBindTexture(GL_TEXTURE_2D, 0);
}Shader#
#shader vertex
#version 330 core
layout(location = 0 ) in vec4 position;
layout(location = 1 ) in vec2 texCoord;
out vec2 v_TextCoord;
void main()
{
gl_Position=position;//自动转换,X, Y, Z:如果缺省,默认补 0.0。W:如果缺省,默认补 1.0
v_TextCoord=texCoord;//从顶点着色器获取到的又传出来
}
#shader fragment
#version 330 core
//layout(location = 0 ) out vec4 color; //这里应该不需要指定layout
out vec4 color;
in vec2 v_TextCoord;
uniform vec4 u_Color;//同意u_开头表示uniform变量
uniform sampler2D u_Texture;//目前给了2
void main()
{
//color=vec4(0.2,0.3,0.8,1.0);
//对待定纹理坐标进行采样
//u_Texture: 去 u_Texture 号纹理单元(GL_TEXTURE_N) 找那张已经绑好的图片。
//v_TextCoord: 纹理坐标,告诉 GPU 从图片的哪个位置采样
vec4 textColor = texture(u_Texture, v_TextCoord);
//color=u_Color;
color=textColor;//只要纹理颜色
}Main#
#ifdef LY_EP17
#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <iostream>
#include <fstream>
#include <sstream>
#include <string>
#include "Renderer.h"
#include "VertexBuffer.h"
#include "VertexBufferLayout.h"
#include "IndexBuffer.h"
#include "VertexArray.h"
#include "Shader.h"
#include "Texture.h"
int main(void)
{
{
#pragma region 一些初始化
GLFWwindow* window;
// 初始化 GLFW 库,失败则退出
if (!glfwInit())
return -1;
//强制指定使用 Core Profile(核心模式),如果
//没有手动写着色器则不会渲染;如果不是核心模式,
//在固定管线中默认颜色是白色,且默认顶点在NDC标准
//设备坐标系中
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
//这是兼容配置模式,会让VAO0成为默认对象
//glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_COMPAT_PROFILE);
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
//解决白屏问题1:先创建一个不可见的窗口,清屏并交换缓冲后再显示窗口
glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE);
// 创建窗口对象
window = glfwCreateWindow(640, 480, "Hello World", NULL, NULL);
//验证:创建windows之后马上整个白屏了
//system("pause");
if (!window)
{
// 创建失败则清理并退出
glfwTerminate();
return -1;
}
// 将当前窗口的上下文设置为 OpenGL 渲染的目标
glfwMakeContextCurrent(window);
glfwSwapInterval(2);
// 初始化 GLEW 以加载 OpenGL 函数指针,需在有上下文后执行
if (glewInit() != GLEW_OK)
{
std::cout << "Error!" << std::endl;
}
#pragma endregion
// 定义三角形的顶点坐标(CPU 内存)
float positions[] = {
-0.5f, -0.5f,0.0f,0.0f,//0
0.5f, -0.5f,1.0f,0.0f,//1
0.5f, 0.5f,1.0f,1.0f,//2
//0.5f, 0.5f,
-0.5f, 0.5f,0.0f,1.0f,//3
//-0.5f, -0.5f,
};
//
unsigned int indices[] = {
0,1,2,
2,3,0
};
//默认情况下,OpenGL 是“覆盖”模式。如果一个像素点已经有颜色了(比如背景色),新画上去的像素会直接把旧的顶掉。开启这个开关后,OpenGL 就不再简单地覆盖,而是会把“新颜色”和“旧颜色”按照某种比例混合在一起
glEnable(GL_BLEND);
//把要画上去的新颜色称为 源 (Source, 简称 src),把已经在屏幕上的旧颜色称为 目标 (Destination, 简称 dst)
//Result = (src x F_src) + (dst x F_dst)
//F_src表示新颜色的比例,F_dst表示旧颜色的比例。比如 F_src = 0.3,那么GL_ONE_MINUS_SRC_ALPHA时 F_dst = 0.7, GL_SRC_ALPHA时 F_dst = 0.3
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
VertexArray va;
//申请创建一个GPU上的缓冲区,绑定并复制进去数据构造函
//数中已经绑定了,这样glVertexAttribPointer才有效果
VertexBuffer vb(positions, 4 * 4 * sizeof(float));
VertexBufferLayout layout;
layout.Push<float>(2);
layout.Push<float>(2);
va.AddBuffer(vb, layout);
//申请创建一个GPU上的缓冲区,绑定并复制进去数据
IndexBuffer ib(indices, 6);
//shader.Bind();
//shader.SetUniform4f("u_Color", 0.8f, 0.3f, 0.8f, 1.0f);
//这里绑定了纹理,shader里就能取到
Texture texture("res/textures/ChernoLog.png");
texture.Bind(2);//默认绑定0
//===这里故意把他解绑了(假设他去绑定别的去了)===
vb.Unbind();
//shader.Unbind();
va.Unbind();
//element_array_buffer 和vertexAttribArray不能
//在解绑vao之前处理,否则就记录进去了
ib.Unbind();
//========================================
float r = 0.0f;
float increment = 0.05f;
Renderer renderer;
Shader shader("res/shaders/Basic.shader");
shader.Bind();
shader.SetUniform1i("u_Texture", 2);
//解决白屏问题2:在进入 while 循环前,手动清一次屏并交换缓冲
//设置“清除颜色”
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
//用上面选好的“油漆”填满整个颜色缓冲区(Color Buffer)
glClear(GL_COLOR_BUFFER_BIT);
//交换缓冲区
glfwSwapBuffers(window); // 这一步把“深绿色”推送到显卡
//解决白屏问题3:最后才显示窗口
glfwShowWindow(window);
// 游戏/渲染主循环
while (!glfwWindowShouldClose(window))
{
// 清理屏幕颜色缓冲区
renderer.Clear();
//必须先绑定program,因为vao不负责着色器程序的切换
//va.Bind();
if (r > 1.0f)
increment = -0.05f;
else if (r < 0.0f)
increment = 0.05f;
r += increment;
//绘图前重新绑定
shader.Bind();
//在u_Color的位置上设置数值
shader.SetUniform4f("u_Color", r, 0.3f, 0.0f, 1.0f);
//========设置uniform========
renderer.Draw(va, ib, shader);
// 交换前后缓冲区以刷新画面
glfwSwapBuffers(window);
// 轮询并处理窗口事件(如键盘输入、关闭动作)
glfwPollEvents();
}
//最后解绑vao
//glBindVertexArray(0);
}
// 退出前清理资源
//glfwTerminate会破坏Context,导致析构函数中的glGetError()
//返回一个OpenGL错误
glfwTerminate();
return 0;
}
#endif