LearnOpenGL学习笔记(二) - 着色器与纹理

本文最后更新于 2024年7月15日 晚上

喘口气。

着色器

GLSL

典型着色器程序的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
#version version_number
in type in_variable_name;
in type in_variable_name;
out type out_variable_name;
uniform type uniform_name;
int main()
{
// 处理输入并进行一些图形操作
...
// 输出处理过的结果到输出变量
out_variable_name = weird_stuff_we_processed;
}

对于Vertex Shader,输入变量被称为Vertex Attribute。在OpenGL中一般至少能声明16个Vertex Attribute,每个含4个分量。

向量

向量是GLSL中最常用的数据类型。包含vecnbvecnivecnuvecndvecn。前缀代表分量的基本类型,后缀n代表维度数。一般使用vecn

使用.x.y.z.w来获取它们的第1、2、3、4个分量。GLSL也允许对颜色使用rgba,或是对纹理坐标使用stpq访问相同的分量。

与CG类似,可以通过重组(Swizzling)的方式填充向量分量。

1
2
3
4
vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;

与此同时,还可以使用向量构造函数直接给向量变量复制,如vec2 vect = vec2(0.5, 0.7)

输入输出

inout关键字用于定义着色器的输入和输出。只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去(前一阶段的输出变量的变量名,应当与后阶段的输入变量的变量名相同)。但在顶点和片段着色器中会有点不同。

对于Vertex Shader,其特殊点在于location关键字。location定义了着色器从顶点数据的哪一部分接收数据。例如,在Vertex Shader中,我定义了两个vec4,第一个是位置数据aPos,它的location是0;第二个是颜色数据aCol,它的location是1。

使用layout (location = 0)定义某输入变量的location

对于Fragment Shader,应当始终保证存在一个vec4型输出变量,用于输出最终颜色。

Uniform

Uniform用于在cpp程序中向着色器输入数据,改变其表现。

如其名,Uniform在每个着色器程序中都是独一无二的。在这个Program链接的所有Shader中,只能存在一个相同名称的Uniform

使用uniform关键字定义Uniform变量。

1
uniform vec4 ourColor;

在cpp程序中改变uniform的代码如下:

1
2
3
4
5
6
7
float timeValue = glfwGetTime(); //获取当前程序运行的秒数
float greenValue = (sin(timeValue) / 2.0f) + 0.5f;
//使用glGetUniformLocation函数,在shaderProgram程序中获取名为"outColor"的Uniform变量位置
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
//获取位置后,使用glUniform4f函数,以之前获取的位置为参数,设置它的值。
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

更新uniform值时,必须Use它所在的Program,否则更新无效。

除glUniform4f外,还有glUniform3i(ivec3)、glUniformfv(float[]或vecn)、glUniformui(unsigned int)等。

多顶点属性

考虑如下顶点数据:

1
2
3
4
5
6
float vertices[] = {
// 位置 // 颜色
0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 左下
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 顶部
};

我们知道,可以使用layout(location = x)来标记不同的顶点属性,通过gVertexAttribPointer来告诉程序该如何处理这些不同的属性。

1
2
3
4
5
6
// 位置属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 颜色属性
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3* sizeof(float)));
glEnableVertexAttribArray(1);

自定义着色器类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
#ifndef SHADER_H
#define SHADER_H

#include <glad/glad.h>

#include <string>
#include <fstream>
#include <sstream>
#include <iostream>

class Shader
{
public:
unsigned int ID;
// constructor generates the shader on the fly
// ------------------------------------------------------------------------
Shader(const char* vertexPath, const char* fragmentPath)
{
// 1. retrieve the vertex/fragment source code from filePath
std::string vertexCode;
std::string fragmentCode;
std::ifstream vShaderFile;
std::ifstream fShaderFile;
// ensure ifstream objects can throw exceptions:
vShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);
fShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);
try
{
// open files
vShaderFile.open(vertexPath);
fShaderFile.open(fragmentPath);
std::stringstream vShaderStream, fShaderStream;
// read file's buffer contents into streams
vShaderStream << vShaderFile.rdbuf();
fShaderStream << fShaderFile.rdbuf();
// close file handlers
vShaderFile.close();
fShaderFile.close();
// convert stream into string
vertexCode = vShaderStream.str();
fragmentCode = fShaderStream.str();
}
catch (std::ifstream::failure& e)
{
std::cout << "ERROR::SHADER::FILE_NOT_SUCCESSFULLY_READ: " << e.what() << std::endl;
}
const char* vShaderCode = vertexCode.c_str();
const char * fShaderCode = fragmentCode.c_str();
// 2. compile shaders
unsigned int vertex, fragment;
// vertex shader
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &vShaderCode, NULL);
glCompileShader(vertex);
checkCompileErrors(vertex, "VERTEX");
// fragment Shader
fragment = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragment, 1, &fShaderCode, NULL);
glCompileShader(fragment);
checkCompileErrors(fragment, "FRAGMENT");
// shader Program
ID = glCreateProgram();
glAttachShader(ID, vertex);
glAttachShader(ID, fragment);
glLinkProgram(ID);
checkCompileErrors(ID, "PROGRAM");
// delete the shaders as they're linked into our program now and no longer necessary
glDeleteShader(vertex);
glDeleteShader(fragment);
}
// activate the shader
// ------------------------------------------------------------------------
void use()
{
glUseProgram(ID);
}
// utility uniform functions
// ------------------------------------------------------------------------
void setBool(const std::string &name, bool value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), (int)value);
}
// ------------------------------------------------------------------------
void setInt(const std::string &name, int value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), value);
}
// ------------------------------------------------------------------------
void setFloat(const std::string &name, float value) const
{
glUniform1f(glGetUniformLocation(ID, name.c_str()), value);
}

private:
// utility function for checking shader compilation/linking errors.
// ------------------------------------------------------------------------
void checkCompileErrors(unsigned int shader, std::string type)
{
int success;
char infoLog[1024];
if (type != "PROGRAM")
{
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(shader, 1024, NULL, infoLog);
std::cout << "ERROR::SHADER_COMPILATION_ERROR of type: " << type << "\n" << infoLog << "\n -- --------------------------------------------------- -- " << std::endl;
}
}
else
{
glGetProgramiv(shader, GL_LINK_STATUS, &success);
if (!success)
{
glGetProgramInfoLog(shader, 1024, NULL, infoLog);
std::cout << "ERROR::PROGRAM_LINKING_ERROR of type: " << type << "\n" << infoLog << "\n -- --------------------------------------------------- -- " << std::endl;
}
}
}
};
#endif

Shader对象的构造必须要在加载完GLAD proc以后,否则报错。

纹理

简介

纹理(Texture)用于在不增加顶点数量的情况下添加物体的细节。它就像一层贴纸一样贴在几何体上。

纹理也可以用来存储数据。

纹理“贴”到几何体上的过程被称为映射(Map)。纹理坐标(Texture Coordinate)用于指定某个顶点该从纹理的哪个位置采样(Sample,采集Fragment颜色)。非顶点的几何体区域通过片段插值(Fragment Interpolation)采样。

纹理坐标以左下角为原点,右上角为(1, 1)点。

对于超出[0, 1]范围的纹理坐标,可以指定纹理环绕方式来处理。

环绕方式 描述
GL_REPEAT 对纹理的默认行为。重复纹理图像。(用于二方连续纹理)
GL_MIRRORED_REPEAT 和GL_REPEAT一样,但每次重复图片是镜像放置的。
GL_CLAMP_TO_EDGE 纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。(Unity中默认模式)
GL_CLAMP_TO_BORDER 超出的坐标为用户指定的边缘颜色。

img

使用glTexParameterx函数对特定坐标轴设置环绕方式。其中的‘x’代表数据类型,如i(int)、fv(float[])等。

1
2
3
4
//参数一指定纹理目标
//参数二指定参数选项以及坐标轴。S轴(x轴)T轴(y轴)
//参数三指定环绕方式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);

选择GL_CLAMP_TO_BORDER时,使用float数组传入颜色数据。

1
2
float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

纹理过滤

纹理坐标可以是任意精度的浮点值,但纹理本身的分辨率却是有限的。因此,OpenGL需要知道,当指定一个纹理坐标时,该如何采样这个点上的像素。最为常见的是GL_NEARESTGL_LINEAR。前者选择最接近坐标的哪个像素,后者会基于坐标附近的像素,计算出插值。像素中心离坐标越近,它对最终颜色的贡献就越大。

二者的主要区别在于,前者会显得更“锯齿”,后者会显得更“模糊”。

当缩放几何体的时候,常常需要对纹理过滤进行设置,通常的做法是在纹理被缩小的时候使用邻近过滤,被放大时使用线性过滤。因为纹理缩小时,纹理像素也会变小,在视觉上,它的“分辨率似乎提高了”。因此,此时采用NEAREST过滤,纹理看起来就不会那么“锯齿化”。纹理放大时,像素看起来会更”明显“,所以使用LINEAR方法让纹理像素不那么明显,过渡更加平滑。

使用glTexParameterx函数为放大和缩小操作指定过滤方式。

1
2
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

多级渐远纹理

在我们当前的理解中,无论物体远近,其被映射的纹理的分辨率是不变的。对于非常远的物体,它们只会产生很少的Fragment。而OpenGL需要在如此高分辨率的纹理上拾取区区几个像素,是非常困难,并且效果不好的。为了解决这一问题,OpenGL引入了多级渐远纹理(Mipmap)。

img

Mipmap中,每个纹理的大小是前一个纹理的二分之一。当物体与相机的距离超过一定阈值后,OpenGL会采用小一级的纹理进行采样。

通过glGenerateMipmap函数创建Mipmap。

与纹理过滤选项类似,OpenGL提供了多种Mipmap匹配选项,用于缓解阈值附近Mipmap切换突兀的问题。

过滤方式 描述
GL_NEAREST_MIPMAP_NEAREST 使用最邻近的多级渐远纹理来匹配像素大小,并使用邻近插值进行纹理采样
GL_LINEAR_MIPMAP_NEAREST 使用最邻近的多级渐远纹理级别,并使用线性插值进行采样
GL_NEAREST_MIPMAP_LINEAR 在两个最匹配像素大小的多级渐远纹理之间进行线性插值,使用邻近插值进行采样
GL_LINEAR_MIPMAP_LINEAR 在两个邻近的多级渐远纹理之间使用线性插值,并使用线性插值进行采样

使用glTexParameteri设置Mipmap过滤方式。

1
2
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

一般只会对MIN_FILTER选项使用Mipmap。对MAG_FILTER使用Mipmap是不正确的。

加载与创建

头文件stb_image.h中的stbi_load函数接收图片路径作为输入,将图片的宽度、高度和颜色通道数输出到三个int变量上。

1
2
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);

glGenBuffers类似,使用glGenTextures(int cnt, unsigned int* addr)生成纹理对象并获取句柄。

使用glBindTexture(GL_TEXTURE_2D, unsigned int texture)绑定纹理对象句柄与上下文目标。

使用glTexImage2d将图片信息复制到上下文目标中。

1
2
3
//上下文目标 | Mipmap级别 | 纹理存储格式 | 图片宽度 | 图片高度 | 总是为0 | 原图的格式 | 原图的数据类型 | 图像数据
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);

glBufferData类似,glTexImage2d的作用就是让当前绑定的纹理对象附加上真正的纹理图像。

glGenerateMipmap(GL_TEXTURE_2D)让OpenGL自动为我们生成、配置Mipmap,无需手动配置。

完成纹理和Mipmap生成后,应当释放图片内存:stbi_image_free(data);

完整的生成纹理过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
// 为当前绑定的纹理对象设置环绕、过滤方式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 加载并生成纹理
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
if (data)
{
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
}
else
{
std::cout << "Failed to load texture" << std::endl;
}
stbi_image_free(data);

纹理单元

sample2d类型uniform无需使用glUniform赋值。但glUniform可以设置sampler2d的位置值,这样我们就能给着色器设置多个纹理。一个纹理的位置值称为一个纹理单元(Texture Unit),其默认值为0。

使用glActiveTexture(GL_TEXTUREX)激活X号纹理单元, 随后使用glBindTexture为该位置值的纹理单元绑定纹理对象。

1
2
3
4
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture2);

使用自定义着色器类的setInt函数设置纹理单元位置值。

1
ourShader.setInt("texture2", 1);

在OpenGL中,纹理坐标的原点在左下角。而多数图像文件格式如PNG、JPEG等原点在左上角。所以直接加载这类图片会导致图片上下颠倒。

使用stbi_set_flip_vertically_on_load(true)反转图片y轴。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
#include <iostream>
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <valarray>
#include "shader_s.h"
#include "stb_image.h"

using namespace std;

float vertices[] = {
// ---- 位置 ---- ---- 颜色 ---- - 纹理坐标 -
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // 右上
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下
-0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f // 左上
};

unsigned int indices[] = {
0, 1, 2,
0, 2, 3
};

void frame_buffer_size_callback(GLFWwindow* window, int width, int height) {
glViewport(0, 0, width, height);
}

int main() {
// glfw初始化
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
GLFWwindow* window = glfwCreateWindow(800, 600, "LearnOpenGL", NULL, NULL);
if (window == NULL) {
cout << "Failed to create window" << endl;
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
cout << "Failed to load proc" << endl;
glfwTerminate();
return -1;
}
glViewport(0, 0, 800, 600);
glfwSetFramebufferSizeCallback(window, frame_buffer_size_callback);
Shader shader("C:\\Users\\msik\\CLionProjects\\LearnOpenGL\\Shaders\\vertex.glsl", "C:\\Users\\msik\\CLionProjects\\LearnOpenGL\\Shaders\\fragment.glsl");

// 加载纹理
unsigned char* data[2];
int width[2], height[2], channels[2];
stbi_set_flip_vertically_on_load(true);
data[0] = stbi_load("C:\\Users\\msik\\CLionProjects\\LearnOpenGL\\container.jpg", &width[0], &height[0], &channels[0], 0);
data[1] = stbi_load("C:\\Users\\msik\\CLionProjects\\LearnOpenGL\\awesomeface.png", &width[1], &height[1], &channels[1], 0);
unsigned int textures[2];
glGenTextures(2, textures);

// 纹理1
glBindTexture(GL_TEXTURE_2D, textures[0]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
if (data[0]) {
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width[0], height[0], 0, GL_RGB, GL_UNSIGNED_BYTE, data[0]);
glGenerateMipmap(GL_TEXTURE_2D);
} else {
cout << "Failed to load texture 1" << endl;
}

// 纹理2
glBindTexture(GL_TEXTURE_2D, textures[1]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
if (data[1]) {
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width[1], height[1], 0, GL_RGBA, GL_UNSIGNED_BYTE, data[1]);
glGenerateMipmap(GL_TEXTURE_2D);
} else {
cout << "Failed to load texture 2" << endl;
}

// 释放图像数据
stbi_image_free(data[0]);
stbi_image_free(data[1]);

// VAO
unsigned int VAO;
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);

// VBO
unsigned int VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

// EBO
unsigned int EBO;
glGenBuffers(1, &EBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

// 顶点属性PTR
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
glEnableVertexAttribArray(2);

shader.use();
shader.setInt("texture1", 0);
shader.setInt("texture2", 1);

while (!glfwWindowShouldClose(window)) {
glClearColor(0.2f, 0.3f, 0.4f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);

// 绑定纹理
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, textures[0]);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, textures[1]);

shader.use();
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

glfwSwapBuffers(window);
glfwPollEvents();
}

glfwTerminate();
return 0;
}
1
2
3
4
5
6
7
8
9
10
#version 330 core
in vec3 col;
in vec2 uv;
out vec4 fragcolor;
uniform sampler2D texture1;
uniform sampler2D texture2;

void main(){
fragcolor = mix(texture(texture1,uv),texture(texture2,uv),0.2f);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aCol;
layout (location = 2) in vec2 aTex;

out vec3 col;
out vec2 uv;

void main(){
gl_Position = vec4(aPos,1.0);
col = aCol;
uv = aTex;
}

LearnOpenGL学习笔记(二) - 着色器与纹理
http://example.com/2024/07/12/LearnOpenGL学习笔记(二) - 着色器与纹理/
作者
Yoi
发布于
2024年7月12日
许可协议