纹理对象、采样状态与多纹理绑定
纹理对象、采样状态与多纹理绑定:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能解释图片像素、texture object、texture unit、target binding 与 sampler uniform 的区别
- 能判断 channels、internal format、source format、type 与 unpack alignment 并正确上传图片
- 能判断 wrap、MIN/MAG filter、mipmap levels 如何共同决定纹理完整性和采样结果
- 能实现 UV attribute、插值、sampler2D、
texture()与两纹理mix()的完整链路 - 能在 Demo 中复现平铺、镜像、边缘夹取、NEAREST、LINEAR 与 mipmap 效果
为什么贴图不只是把图片交给 GPU
上一章你已经能给三角形的每个顶点指定颜色,让 GPU 插值出一片渐变。可现实里的物体表面远不止纯色或渐变——一面砖墙、一张木桌、一个人物的脸,全是密密麻麻的花纹和细节。要靠顶点颜色画出砖墙的每一道缝,得堆上成千上万个顶点,谁也受不了。
换个思路:与其用几何体硬画细节,不如直接贴一张照片上去。就像给一面白墙贴壁纸、给玩偶穿一件印花衣服——墙还是那面墙,但看上去有了砖纹;衣服的图案不是缝出来的,是印上去的。这张「贴上去的图」就是本章的主角。
贴图要解决一个关键问题:图是方方正正的一张照片,可几何体的形状千奇百怪。这张图的每个位置,到底该贴到几何体的哪个点上? 没有一套说清「图的这个角对应物体的那个角」的规则,贴图就成了乱糊一气。这一章讲的就是这套规则,以及贴上去之后怎么把图贴得清楚、贴得自然。
纹理从图片变成可采样资源
decode · allocate · upload/mipmap · bind/sample/destroy
CPU pixels + flip policy
合法 texture storage
每级数据完整
正确颜色与资源释放
纹理:贴在表面的那张图
我们贴上去的这张图,在图形学里就叫 ↡一张贴到几何体表面的图像数据,用来给表面提供可按坐标查询的颜色或其他信号。图片文件先被解码成 CPU pixels,再上传到↡保存维度、internal format、mip levels和采样参数的OpenGL资源对象;它不是文件路径,也不是 sampler uniform。
为什么用它?因为它把「表面长什么样」和「物体是什么形状」彻底分开了:形状仍由那几个顶点定,表面细节全交给图。在管线里,纹理在片段着色器阶段起作用——上一章你已经知道,片段着色器负责给每个像素定颜色;有了纹理,这个颜色就可以不再是写死的,而是「从图里查出来的」。
纹理坐标(UV):图和几何体怎么对齐
要让 GPU 知道「图的哪个位置贴到几何体的哪个点」,靠的是 ↡给每个顶点附带的一对坐标 (u, v),告诉 GPU「这个顶点对应纹理图上的哪个位置」。范围 0~1:(0,0) 是图的左下角,(1,1) 是图的右上角。常简称 UV。(常简称 UV)。它是一对范围在 0 到 1 之间的数:约定 (0, 0) 是图的左下角,(1, 1) 是图的右上角,正中间就是 (0.5, 0.5)。
做法和上一章给顶点配颜色一模一样:每个顶点附带一对 UV,告诉 GPU「我这个角对应图上的这个位置」。比如让三角形左下角顶点配 UV (0, 0)、右下角配 (1, 0)、顶部配 (0.5, 1),就等于说「把图的左下角钉在三角形左下角、右下角钉在右下角……」——图就被这几枚「图钉」绷在了三角形上。
下面这张图把这套对应关系画了出来:左边是 UV 坐标系(注意原点在左下),右边是纹理图,看四个顶点的 UV 如何一一钉住图的四角:
那三角形内部、不在顶点上的那些像素呢?还记得上一章讲的片段插值吗——和颜色一样,顶点的 UV 也会被光栅化按远近自动混合:顶点只钉了几个角的 UV,内部每个片段的 UV 都是「混」出来的。于是图就被平滑地、连续地铺满了整个三角形。
采样:拿着 UV 去图里取颜色
有了每个片段的 UV,片段着色器就能干一件事:拿这对 UV 当「图上的地址」,去纹理里取出那个位置的颜色。这个「按坐标去图里取色」的动作叫 ↡片段着色器拿着该片段的 UV 坐标,去纹理图里取出对应位置颜色的动作。在 GLSL 里用 texture(纹理, UV) 完成。是「贴图」最终落到每个像素上的那一步。(sampling)。
整条链路串起来就是:顶点给 UV → 光栅化把 UV 插值到每个片段 → 片段着色器拿着自己的 UV 去纹理里采样取色 → 输出。一张图就这么贴上了几何体。后面 §6 你会看到,采样在 GLSL 里只是一句 texture(...)。
一次纹理采样包含四层决策
coordinate · address · LOD/filter · shader combine
每片段坐标
越界坐标归约
重建采样值
颜色进入 framebuffer
纹理环绕:UV 超出 0~1 时怎么办
UV 约定在 0~1,但你完全可以给顶点配超出这个范围的 UV,比如 (0, 0) 到 (3, 3)。这时 GPU 该怎么显示「图之外」的部分?这由 ↡UV 坐标超出 0~1 范围时,GPU 如何取色的规则。常见三种:REPEAT(重复平铺)、MIRRORED_REPEAT(镜像平铺)、CLAMP_TO_EDGE(夹到边缘、拉伸边缘像素)。分别对 U、V 两个方向单独设置。方式决定,常见三种:
- REPEAT(重复):默认。把图一张挨一张地平铺。UV 拉到
(3, 3)就铺成 3×3 张。 - MIRRORED_REPEAT(镜像重复):也平铺,但每隔一张镜像翻转一次,让接缝处对齐、不突兀。
- CLAMP_TO_EDGE(夹到边缘):超出的部分一律用最边上那排像素的颜色「拉长」填满,于是边缘像被向外抹开。
桌面 OpenGL 还提供 GL_CLAMP_TO_BORDER,越界时返回由 GL_TEXTURE_BORDER_COLOR 设置的颜色;WebGL2 不提供该 wrap mode,因此跨平台示例不能直接照搬。S/T 分别对应 U/V 两个方向,必须按需要各设一次。
光看文字不够直观——去本章 §5 的 Demo,把「UV 缩放」拉到大于 1(比如 3),再依次切这三种环绕,亲眼看平铺、镜像、边缘拉伸的差别。
纹理过滤:图被放大 / 缩小时怎么取色
UV 是连续的小数,纹理却是一格格离散的像素——当一个片段的 UV 落在「两格像素之间」(贴图被放大、一个纹理像素铺到了好几个屏幕像素时尤其明显),该取哪一格的颜色?这由 ↡片段的 UV 落在纹理像素之间时如何取色的规则。NEAREST:取最近的那一格(结果硬、有马赛克块感);LINEAR:按距离在邻近几格间加权混合(结果平滑)。分放大(MAG)、缩小(MIN)两种情况设置。决定,两种基本方式:
- NEAREST(邻近过滤):取离 UV 最近的那一格像素颜色。放大后能看到一块块清晰的「马赛克」方块——像素风游戏就爱用它。
- LINEAR(线性过滤):在邻近几格之间按距离加权混合,得到平滑过渡。放大后柔和但偏糊。
同样去 §5 的 Demo:把「放大观察」拉到 12(等于把图放得很大),再对比 NEAREST 与 LINEAR——一个是硬边方块,一个是平滑渐变,差别一目了然。
多级渐远纹理(mipmap):远处缩小时怎么办
上面说的是放大。反过来,当物体离得很远、一整张大图被挤进屏幕上区区几个像素时,又会出问题:屏幕一个像素要「代表」原图一大片像素,硬取一格会闪烁、出噪点。
解决办法是 ↡为一张纹理预先生成的一系列逐级减半的缩小版(原图、1/2、1/4、1/8……)。物体离得远、贴图被缩得小时,GPU 自动改从尺寸合适的那一级取色,避免远处闪烁、噪点。常简称 mipmap。(mipmap):预先为这张图生成一整套逐级减半的缩小版(原图、1/2、1/4、1/8……),堆成一座「纹理金字塔」。物体离得越远、贴图显示得越小,GPU 就自动从金字塔里尺寸越合适的那一级去取色,省力又干净。
实际中通常一句 glGenerateMipmap 就让 GPU 自动算出整套金字塔(§6 会写到),你不必手动准备这些缩小版。
黑纹理通常是 Completeness Contract 破裂
base image · mip levels · legal filters · pixel transfer
level 0 已定义
MIN filter 所需 levels
MAG 不接受 mip filters
行距和方向正确
↡基础级尺寸格式、所需mip levels与采样参数共同满足OpenGL可采样要求的状态是黑纹理排查的第一项。若 MIN_FILTER 使用 *_MIPMAP_*,从 base level 到所需最小尺寸的 levels 必须已定义或由 glGenerateMipmap 生成;MAG_FILTER 只能使用 NEAREST 或 LINEAR,没有 mipmap 版本。
纹理单元:一次想用好几张图
到这里都是贴一张图。但很多效果要同时用好几张图——比如一张底图叠一张笑脸贴纸。问题来了:片段着色器里怎么分清「这是第 1 张、那是第 2 张」?
办法是 ↡显卡上一组带编号的「插槽」(0、1、2……至少 16 个)。把每张纹理绑到一个编号的单元上,再告诉着色器里每个采样器去第几号单元取图,就能在一次绘制里同时用多张纹理。(texture unit):显卡提供一排带编号的「插槽」(0 号、1 号……至少 16 个)。流程是——先用 activeTexture 选中某个编号的插槽,把一张图绑上去;再告诉着色器里的某个采样器「你去几号插槽取图」。这样一来,着色器里就能区分多张纹理了。这也是为什么用多张图时既要 activeTexture、又要给每个采样器单独指定单元号。具体写法见 §6 最后一步。
Object、Unit、Binding 与 Sampler 是四个角色
select unit · bind object · point sampler · lookup
选择第 i 个插槽
该 unit 的 2D binding
shader 保存 unit index
按状态返回 texel
采样器 uniform 保存的是整数 unit index,例如 0,不是 GLuint texture 的 object ID,也不是 GL_TEXTURE0 枚举值。glActiveTexture(GL_TEXTURE0+i) 选择 unit,glBindTexture(GL_TEXTURE_2D,id) 修改该 unit 的 2D target binding,glUniform1i(location,i) 才把 shader sampler 指向同一 unit。可用 GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS 等 limits 查询能力,不能把“至少 16”当作所有 stage 的任意可用数量。
图片上传:尺寸、Channels、Format 与行对齐
stbi_load 返回 width、height、channel count 和 pixel pointer。灰度、RGB、RGBA 输入应分别选择匹配的 source format;internal format 决定 GPU 怎样存储,source format/type 描述 CPU bytes 怎样解释。初学示例可令两者都为 GL_RGB 或 GL_RGBA,但不能对四通道图片硬写 GL_RGB 后期待 alpha 保留。
int width = 0, height = 0, channels = 0;
stbi_set_flip_vertically_on_load(true);
unsigned char* pixels = stbi_load(path, &width, &height, &channels, 0);
if (!pixels) throw std::runtime_error(stbi_failure_reason());
GLenum format = channels == 4 ? GL_RGBA : channels == 3 ? GL_RGB : GL_RED;
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0,
format, GL_UNSIGNED_BYTE, pixels);
glGenerateMipmap(GL_TEXTURE_2D);
stbi_image_free(pixels);默认 GL_UNPACK_ALIGNMENT=4。RGB 图片一行若是 width*3 bytes 且不能被 4 整除,驱动会按错误行距读下一行;上传前设 alignment 1,或提供满足对齐的 row layout。WebGL2 对应 UNPACK_ALIGNMENT 和 UNPACK_FLIP_Y_WEBGL pixel-store state。颜色空间的 sRGB internal format 会在后续 Gamma Correction 章系统处理,本章先保持线性数据契约。
动手:换张图,调环绕、过滤、看采样
下面就是本章主 Demo。最上面一排「纹理」按钮可以切换贴在方块上的图:默认的 UV 测试图(棋盘格 + 网格线 + 四角异色 + 中心紫圆,专门用来看图被怎样平铺、采样),以及三张一眼认得出的真实感纹理——木板、砖墙、笑脸。同一套环绕/过滤/缩放规则作用在哪张图上都一样,换图只是为了让「贴图」这件事更直观。其余四组控件对应本章核心概念:环绕方式、过滤方式、UV 缩放(看环绕)、放大观察(看过滤)。
猜一猜:先切到砖墙,把「UV 缩放」拉到 3——一面砖墙会平铺成几面?再切 MIRRORED_REPEAT,砖缝在接缝处会不会对齐?然后切到笑脸,把「放大观察」拉到 12,对比 NEAREST 与 LINEAR:一张笑脸的眼睛边缘,一个是硬邦邦的马赛克块、一个是平滑发糊,差别一眼可见。先动手拖一拖,再看下面的代码。
纹理演示加载中…
调「UV 缩放」改的是顶点 UV 的范围(拉到 3 等于把 UV 推到了 (0,0)~(3,3),于是越界部分交给环绕方式处理);调「放大观察」相当于把图凑近放大看(一个纹理像素铺到很多屏幕像素,于是过滤方式的差别被放大)。这两件事正是 §3 讲的环绕与过滤——现在它们就在你手里。
代码逐段拆解
把贴图的全过程摊开看。大致分两摊:一摊在 CPU 侧(创建纹理、上传图片、设参数),一摊在着色器里(拿 UV 采样)。C++/OpenGL 与 WebGL2/TS 互为镜像。
第一步:创建并绑定一个纹理对象
先向显卡申请一个纹理对象拿到「句柄」,再把它绑定为「当前正在操作的纹理」——之后所有设参数、传数据的调用都作用在这个被绑定的纹理上。
unsigned int texture;
glGenTextures(1, &texture); // 申请 1 个纹理对象,拿到句柄
glBindTexture(GL_TEXTURE_2D, texture); // 绑定为「当前的 2D 纹理」
// 之后的 glTexImage2D / glTexParameteri 都作用在它身上const texture = gl.createTexture(); // 申请 1 个纹理对象(返回对象,非数字)
gl.bindTexture(gl.TEXTURE_2D, texture); // 绑定为「当前的 2D 纹理」
// 之后的 gl.texImage2D / gl.texParameteri 都作用在它身上逻辑一一对应,差别在「申请」这一步的返回值:
第二步:设环绕和过滤参数
趁纹理还绑着,把 §3 讲的环绕(U / V 两个方向各设一次)和过滤(放大 MAG / 缩小 MIN 各设一次)定下来。这些参数就是 Demo 里那几个控件背后改的东西。
// 环绕:S=横向(U),T=纵向(V)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
// 过滤:缩小用 LINEAR,放大用 NEAREST(举例)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);// 环绕:S=横向(U),T=纵向(V)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
// 过滤:缩小用 LINEAR,放大用 NEAREST(举例)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);WRAP_S 对应 U(横向)、WRAP_T 对应 V(纵向),可以分开设。把 GL_REPEAT 换成 GL_MIRRORED_REPEAT / GL_CLAMP_TO_EDGE 就切换环绕方式;把 GL_NEAREST / GL_LINEAR 互换就切换过滤方式。
第三步:上传图片数据,并生成 mipmap
把图片的像素数据交给绑定中的纹理,靠 glTexImage2D / gl.texImage2D。它参数最多、最容易写错,逐个对照:
// data 是 stb_image 读出的像素;参数顺序见下方解说
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB,
width, height, 0,
GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D); // 自动生成整套缩小版(mipmap 金字塔)// image 是已加载完的 HTMLImageElement(WebGL2 可直接传图元素)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image);
gl.generateMipmap(gl.TEXTURE_2D); // 自动生成整套缩小版(mipmap 金字塔)C++ 版的 glTexImage2D 九个参数依次是:① 目标 GL_TEXTURE_2D;② mipmap 级别 0(基础级);③ 显卡里存成什么格式(内部格式,如 GL_RGB);④⑤ 图宽、图高;⑥ 边框,永远填 0;⑦ 你传进来的数据是什么格式(源格式);⑧ 数据类型 GL_UNSIGNED_BYTE;⑨ 数据指针。glGenerateMipmap 则让 GPU 一次性算出 §3 那座金字塔。
第四步:顶点带上 UV 坐标
光有纹理还不够,得告诉每个顶点它对应图上的哪个位置——也就是把 UV 加进顶点数据,再像上一章配置颜色属性那样,给它配一个顶点属性指针。
// 顶点数据每行:3 个位置 + 2 个 UV(这里省略位置,只看 UV 在第 4、5 列)
// 例:位置 x,y,z, u,v —— 每个顶点 5 个 float
// 把第 2 号属性(UV,vec2)指向每行偏移 3*float 处,步长 5*float:
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE,
5 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(2);// 把第 2 号属性(UV,vec2)指向每行偏移 3 个 float 处,步长 5 个 float:
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 5 * 4, 3 * 4);
gl.enableVertexAttribArray(2);2 是属性位置编号(对应顶点着色器里 layout (location = 2) in vec2 aTexCoord;);2 是每个 UV 有 2 个分量;步长和偏移按每行 5 个 float、UV 在第 4 个起算。WebGL2 里 sizeof(float) 就是固定的 4 字节。
第五步:着色器里采样
顶点着色器把 UV 用 out 递给片段着色器(中途被插值);片段着色器声明一个 ↡GLSL 里代表一张 2D 纹理的采样器类型。它本身不存图,而是「指向某个纹理单元」的句柄;配合 texture() 用它去采样取色。 接住纹理,再用 ↡GLSL 的内建采样函数。texture(sampler, uv) 表示「拿着 uv 坐标,去 sampler 指向的那张纹理里取出对应颜色」,返回一个 vec4(rgba)。这是采样落到代码上的那一步。 拿 UV 采样取色。两端 GLSL 源码几乎一致,只差版本声明那行:
// —— 顶点着色器 —— 把 UV 递下去
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 2) in vec2 aTexCoord;
out vec2 TexCoord;
void main() {
gl_Position = vec4(aPos, 1.0);
TexCoord = aTexCoord; // 递给片段着色器(会被插值)
}// —— 顶点着色器 —— 把 UV 递下去(WebGL2 用 #version 300 es)
#version 300 es
layout (location = 0) in vec3 aPos;
layout (location = 2) in vec2 aTexCoord;
out vec2 TexCoord;
void main() {
gl_Position = vec4(aPos, 1.0);
TexCoord = aTexCoord; // 递给片段着色器(会被插值)
}片段着色器接住插值后的 TexCoord,用 texture() 去采样——这就是 §3「采样」落到代码上的那一句:
// —— 片段着色器 —— 采样取色
#version 330 core
in vec2 TexCoord;
out vec4 FragColor;
uniform sampler2D ourTexture; // 代表那张纹理
void main() {
FragColor = texture(ourTexture, TexCoord); // 按 UV 采样
}// —— 片段着色器 —— 采样取色(WebGL2 片段着色器必写 precision)
#version 300 es
precision highp float;
in vec2 TexCoord;
out vec4 FragColor;
uniform sampler2D ourTexture; // 代表那张纹理
void main() {
FragColor = texture(ourTexture, TexCoord); // 按 UV 采样
}第六步:用多张图——纹理单元 + mix()
要同时用两张图,就轮到 §3 的纹理单元了:把两张图分别绑到 0 号、1 号单元,再告诉着色器里两个采样器各去几号单元取图。CPU 侧这样安排:
glUseProgram(program);
// 告诉着色器:采样器 texture1 去 0 号单元,texture2 去 1 号(用 1i 传整数单元号)
glUniform1i(glGetUniformLocation(program, "texture1"), 0);
glUniform1i(glGetUniformLocation(program, "texture2"), 1);
// 每帧绘制前:选中单元,绑上对应纹理
glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, tex1);
glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_2D, tex2);gl.useProgram(program);
// 告诉着色器:采样器 texture1 去 0 号单元,texture2 去 1 号(用 1i 传整数单元号)
gl.uniform1i(gl.getUniformLocation(program, "texture1"), 0);
gl.uniform1i(gl.getUniformLocation(program, "texture2"), 1);
// 每帧绘制前:选中单元,绑上对应纹理
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex1);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, tex2);注意采样器接的不是纹理本身,而是「几号单元」——所以用 glUniform1i 传的是单元的整数编号(0、1),不是纹理对象。着色器里则声明两个 sampler2D,用 ↡GLSL 的线性插值内建函数。mix(a, b, t) 返回 a 和 b 按比例 t 的混合:t=0 全取 a,t=1 全取 b,t=0.2 是 80%a + 20%b。常用来把两张纹理的采样结果按比例叠在一起。 把两次采样结果按比例混合:
#version 330 core
in vec2 TexCoord;
out vec4 FragColor;
uniform sampler2D texture1;
uniform sampler2D texture2;
void main() {
// 80% 第一张 + 20% 第二张
FragColor = mix(texture(texture1, TexCoord),
texture(texture2, TexCoord), 0.2);
}#version 300 es
precision highp float;
in vec2 TexCoord;
out vec4 FragColor;
uniform sampler2D texture1;
uniform sampler2D texture2;
void main() {
// 80% 第一张 + 20% 第二张
FragColor = mix(texture(texture1, TexCoord),
texture(texture2, TexCoord), 0.2);
}mix(a, b, 0.2) = 0.8*a + 0.2*b:第三个参数从 0 调到 1,画面就从全是第一张图渐变到全是第二张图。完整可运行源码将在章末提供(M5)。
三步验证纹理采样链
第一步:先验证 Pixels 与 Texture Object
用 2×2 已知颜色测试图核对 width、height、channels、Y flip、source/internal format 和 unpack alignment;只在 level 0 正确后生成 mipmaps。
一次纹理采样包含四层决策
coordinate · address · LOD/filter · shader combine
每片段坐标
越界坐标归约
重建采样值
颜色进入 framebuffer
容易踩的坑
小结
- 纹理就是贴到表面的一张图,把「长什么样」和「是什么形状」分开,省去用海量顶点硬画细节
- **纹理坐标(UV)**逐顶点指定(原点左下、范围 0~1),内部每个片段的 UV 由插值「混」出来
- 环绕决定 UV 越界(
>1或<0)时怎么显示:REPEAT 平铺 / MIRRORED 镜像 / CLAMP_TO_EDGE 拉边 - 过滤决定放大缩小时怎么取色:NEAREST 出马赛克硬块 / LINEAR 平滑;远处缩小用 mipmap 防闪烁
- 着色器里用 sampler2D + texture() 采样;多张图靠纹理单元区分,再用 mix() 按比例混合
练习
问题 1: 在上面的 Demo 里调出两个指定效果:①把同一张图平铺成 3×3;②让放大后的棋盘格呈现清晰的马赛克硬块(而非平滑过渡)。分别说出你动了哪个 / 哪些控件、为什么。
问题 2: 顶点只有 3 个角、各配了一对 UV,可三角形内部成千上万个片段,每个片段的 UV 是哪来的?这和上一章顶点颜色变成渐变是同一个机制吗?
问题 3: 不看上面的代码,写出「在 WebGL2 里把一张已加载好的图片 image 贴到几何体上」的关键步骤:从创建纹理到能在片段着色器里采样。目标效果:方块上原样显示这张图。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 纹理
贴到几何体表面的一张图(通常就是一张图片)。用它给物体表面加细节——砖纹、木纹、花色,省得用成千上万个顶点去硬画。本质是一块存在显卡上、能按坐标查颜色的数据。详见本章「纹理」一节。
- 纹理对象
保存纹理尺寸、internal format、mip levels 和采样参数的 OpenGL resource;图片文件必须先解码并上传,sampler 再通过 texture unit 间接访问它。
- 纹理坐标
给每个顶点附带的一对坐标
(u, v),告诉 GPU「这个顶点对应纹理图上的哪个位置」。范围 0~1:(0,0)是图的左下角、(1,1)是右上角。常简称 UV。详见本章「纹理坐标」一节。- 采样
片段着色器拿着自己那对 UV 坐标,去纹理图里取出对应位置颜色的动作。在 GLSL 里就是一句
texture(纹理, UV)。是「贴图」最终落到每个像素上的那一步。详见本章「采样」一节。- 纹理环绕
UV 坐标超出 0~1 时,GPU 怎么取色的规则。三种常见:REPEAT(一张张重复平铺)、MIRRORED_REPEAT(平铺但每隔一张镜像翻转)、CLAMP_TO_EDGE(用最边上的像素拉长填满)。横向(U)、纵向(V) 可分开设。详见本章「纹理环绕」一节。
- 纹理过滤
片段的 UV 落在「两格纹理像素之间」时怎么取色的规则。NEAREST:取最近一格,放大后是马赛克硬块;LINEAR:在邻近几格间按距离混合,放大后平滑。分放大(MAG)、缩小(MIN) 两种情况设。详见本章「纹理过滤」一节。
- 多级渐远纹理
为一张纹理预先生成的一整套逐级减半的缩小版(原图、1/2、1/4、1/8……),堆成一座「纹理金字塔」。物体离得远、贴图被缩得小时,GPU 自动从尺寸合适的那一级取色,避免远处闪烁、噪点。常简称 mipmap。详见本章「多级渐远纹理」一节。
- 纹理完整性
Base level、所需 mip levels、尺寸格式和采样参数满足 OpenGL 可采样规则的状态;不完整纹理常表现为黑色采样结果。
- 纹理单元
显卡上一排带编号的「插槽」(0 号、1 号……至少有 16 个)。把每张纹理绑到一个编号的单元上,再告诉着色器里每个采样器去第几号单元取图,就能在一次绘制里同时用多张纹理。详见本章「纹理单元」一节。
- sampler2D
GLSL 里代表一张 2D 纹理的采样器类型。它本身不存图,而是「指向某个纹理单元」的句柄;配合
texture()用它去采样取色。详见本章「第五步:着色器里采样」。- texture()
GLSL 的内建采样函数。
texture(sampler, uv)表示「拿着uv坐标,去sampler指向的那张纹理里取出对应颜色」,返回一个vec4(rgba)。是「采样」落到代码上的那一句。详见本章「第五步:着色器里采样」。- mix()
GLSL 的线性插值函数。
mix(a, b, t)按比例t混合:t=0全取a、t=1全取b、t=0.2是 80%a+ 20%b。常用来把两张纹理的采样结果按比例叠在一起。详见本章「第六步:用多张图」。
版本、来源与运行边界
本章以 Joey de Vries 的 LearnOpenGL 原章 为授权改编依据,教学运行基线是 OpenGL 3.3 Core Profile。Khronos 当前发布的规范参照是 OpenGL 4.6 Core Profile;这里用 4.6 规范核查术语和状态合同,但不把 4.6 API 偷偷倒填为原教程内容。GLFW、GLAD、Assimp 与驱动版本都属于运行环境,不能拿“编译通过”替代对 context、资源和 framebuffer 结果的验证。
正式概念与状态责任
- texture:在“纹理对象、采样状态与多纹理绑定”中由纹理对象和当前 active texture unit负责解释其输入、受控状态和可观察结果;运行时以纹理级别查询、unit/target 绑定、sampler 值、GL 错误与采样像素定位它的第一处变化。
- texture unit:在“纹理对象、采样状态与多纹理绑定”中由纹理对象和当前 active texture unit负责解释其输入、受控状态和可观察结果;运行时以纹理级别查询、unit/target 绑定、sampler 值、GL 错误与采样像素定位它的第一处变化。
- wrapping:在“纹理对象、采样状态与多纹理绑定”中由纹理对象和当前 active texture unit负责解释其输入、受控状态和可观察结果;运行时以纹理级别查询、unit/target 绑定、sampler 值、GL 错误与采样像素定位它的第一处变化。
- filtering:在“纹理对象、采样状态与多纹理绑定”中由纹理对象和当前 active texture unit负责解释其输入、受控状态和可观察结果;运行时以纹理级别查询、unit/target 绑定、sampler 值、GL 错误与采样像素定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“上传 texel,配置采样参数,生成 mipmap,并把 sampler 指向已绑定 unit”发生后,纹理对象和当前 active texture unit应怎样改变像素格式、尺寸、wrap/filter、mipmap 完整性、unit 绑定和 sampler 整数;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
纹理对象、采样状态与多纹理绑定:状态合同
把图片字节、纹理对象、采样状态、mipmap、texture unit 与 sampler 对齐
验证场景
官方教程正式概念
logl-05 · 基线帧
texture:固定 context、资源内容与输入事件,执行“上传 texel,配置采样参数,生成 mipmap,并把 sampler 指向已绑定 unit”
冻结输入:texture
纹理对象和当前 active texture unit记录像素格式、尺寸、wrap/filter、mipmap 完整性、unit 绑定和 sampler 整数
结果:得到可重复的初始 GL 状态与资源身份
观测:纹理级别查询、unit/target 绑定、sampler 值、GL 错误与采样像素中的初始快照
预期:纹理对象和当前 active texture unit得到可复查结果,并持续满足“采样所需 mip 层完整,sampler 的整数值表示纹理单元编号而不是对象 ID”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“上传 texel,配置采样参数,生成 mipmap,并把 sampler 指向已绑定 unit”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“采样所需 mip 层完整,sampler 的整数值表示纹理单元编号而不是对象 ID”。
CPU command · GL state · GPU result
纹理对象、采样状态与多纹理绑定:五段轨迹
当前观测:纹理级别查询、unit/target 绑定、sampler 值、GL 错误与采样像素中的初始快照
不变量:采样所需 mip 层完整,sampler 的整数值表示纹理单元编号而不是对象 ID
实验三:单故障与同输入恢复
注入“min filter 要求 mipmap,却既未上传也未生成 mip 层,纹理变成不完整”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有纹理级别查询、unit/target 绑定、sampler 值、GL 错误与采样像素一起恢复才算修复。
Single fault · first divergence · replay
纹理对象、采样状态与多纹理绑定:反例与恢复
故障:min filter 要求 mipmap,却既未上传也未生成 mip 层,纹理变成不完整
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“min filter 要求 mipmap,却既未上传也未生成 mip 层,纹理变成不完整”
采样所需 mip 层完整,sampler 的整数值表示纹理单元编号而不是对象 ID
纹理级别查询、unit/target 绑定、sampler 值、GL 错误与采样像素
最小可重放检查
unit: logl-05
owner: 纹理对象和当前 active texture unit
state_or_resource: 像素格式、尺寸、wrap/filter、mipmap 完整性、unit 绑定和 sampler 整数
command: 上传 texel,配置采样参数,生成 mipmap,并把 sampler 指向已绑定 unit
pass_invariant: 采样所需 mip 层完整,sampler 的整数值表示纹理单元编号而不是对象 ID
single_fault: min filter 要求 mipmap,却既未上传也未生成 mip 层,纹理变成不完整
required_evidence: 纹理级别查询、unit/target 绑定、sampler 值、GL 错误与采样像素复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。