实例化、批次边界与每实例矩阵
实例化、批次边界与每实例矩阵:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能实现一次实例化绘制一大片相同网格:把
glDrawArrays换成glDrawArraysInstanced(..., instanceCount),一次调用画instanceCount个,而不是循环调用instanceCount次 - 能实现「每个实例摆在不同位置」:用
gl_InstanceID配 uniform 数组取偏移,或建一个实例化数组并glVertexAttribDivisor(loc, 1),让每个实例读到属于自己那一份的偏移 / 变换矩阵 - 能判断:实例化数组的属性忘了设
glVertexAttribDivisor(loc, 1),画面会变成什么样?为什么?一个多子网格模型实际还会保留多少绘制批次?
为什么重复小网格会卡住 CPU
想象你要在纸上印一万个一模一样的小图章。笨办法是:拿起图章、蘸墨、盖一个,再拿起、再蘸、再盖……重复一万次。盖图章这个动作本身很快,但每次「拿起、吩咐手去盖」的来回才是真正拖慢你的——一万次来回,胳膊先废了。
聪明办法是:跟工厂说一句「照这一个模子,盖一万个,每个盖在哪、盖多大,看这张表」。模子只有一个,表上一行写一个位置。工厂一口气盖完一万个,而你只吩咐了一遍。
这一章解决的就是:要画很多个一模一样的东西时,怎么只「吩咐」一次、而不是一个一个地吩咐一万遍。没有它,画一千个绕行星飞的小行星就得发起一千次绘制请求,CPU 可能被反复提交命令拖成瓶颈;有了它,提交次数会显著减少。它不保证任何数量都流畅:GPU 仍要处理每个实例的顶点和片段,最终瓶颈要靠 profiler 判断。
实例化:一次 draw call 画一整片
把「吩咐一次、盖一万个」翻译成图形术语,就是 ↡一种绘制技术:用一次绘制调用(一次 draw call)画出很多个相同网格与兼容渲染状态的对象,而不是循环调用很多次。每个实例共享同一份顶点数据,只是各自的变换(位置/缩放/朝向)或某些属性不同。不同网格、材质或 pipeline 要分成不同实例化批次。常用来画大量重复物体:草地、森林、粒子、行星带等。(instancing)。它的前提是:你要画的这一大片东西在同一个批次里共享网格、材质和兼容状态。区别只在每个的位置、大小、朝向(统称「变换」)或每实例属性不同。实例化就是把「这一份几何」连同「每个实例各自的变换」一次性交给 GPU,让它一口气画完。
为什么要这么做?关键在 ↡一次 draw call(绘制调用,如 glDrawArrays / glDrawElements)背后是一趟「CPU 把绘制命令和状态打包、发给 GPU」的通信。这趟通信有驱动校验、状态切换和命令提交等成本;当每个物体都很小、而调用次数很大时,它往往比实际栅格化更早成为瓶颈。实例化把同一批次的 N 次 draw call 压成 1 次,正是省这笔 CPU 提交开销;它不让 GPU 少做每个实例的顶点和片段工作。。每次你调用 glDrawArrays 或 glDrawElements,CPU 都要把绘制命令打包、发给 GPU。当每个物体很小而调用数很大时,这段提交成本常比实际栅格化更早成为瓶颈;实例化只针对这段 CPU 成本,不会让 GPU 少处理每个实例的顶点和片段。下面这张图把「N 次喊话 vs 1 次喊话」摆在一起:
draw call 都是一趟「CPU 喊话 → GPU 干活」的通信,开销在喊话本身。画 N 个相同物体:不实例化要喊 N 遍(CPU 瓶颈);实例化只喊 1 遍(instanceCount = N),GPU 一口气画完。实例化把同一个兼容批次的 N 次 draw call 压成 1 次。你只调用一次带 instanceCount 的绘制函数,GPU 内部就把同一份几何重复画 instanceCount 遍。那「每个实例摆在哪」的信息怎么进去?这是接下来两节的事。先看这张「模子 + 每实例变换表 → 盖出 N 个」的总览:
i 行对应着色器里 gl_InstanceID == i);GPU 照表把这个模子盖出 N 份、各按自己那行的偏移摆好,整批只用一次 draw call。这里的“1 次”有边界:同一个网格、材质和兼容渲染状态可组成一个实例化批次;不同网格、不同材质、不同 shader / pipeline 仍要分批。例如一个行星模型若有 M 个子网格,通常要对每个子网格各做一次 glDrawElementsInstanced。实例化减少的是 CPU / 驱动提交次数,不是让 GPU 少画 instanceCount 份三角形。
怎么让每个实例不一样:gl_InstanceID 与实例化数组
同一个模子盖出来的 N 个实例,如果都摆在同一个位置,就会完全重叠成一个——没意义。所以必须让每个实例读到「属于自己那一份」的变换。OpenGL 给了两条路。
第一条路靠一个内置变量 ↡顶点着色器里的一个内置整数变量,表示「当前正在画的是第几个实例」,从 0 开始递增:第 0 个实例时它是 0、第 1 个实例时是 1……只在实例化绘制时有意义。常见用法是拿它当下标去取「每实例数据」,比如 offsets[gl_InstanceID],给每个实例一个不同的偏移。。在顶点着色器里,它告诉你「现在画的是第几个实例」——第 0 个实例时它是 0,第 1 个实例时是 1,依此类推。你可以拿它当下标去一个 uniform 数组里取偏移:offsets[gl_InstanceID],于是第 i 个实例就读到 offsets[i]、摆到不同位置。简单直接,但有个硬上限:uniform 数组不能太大(受 GPU 的 uniform 容量限制),塞个一百个偏移还行,要塞一万个就爆了。
原书的第一个基准:一次调用画 100 个彩色四边形
原教程先用最小案例建立直觉:一个四边形由 2 个三角形、共 6 个顶点组成;在 NDC 中准备一个 10 x 10 的 translations[100] 偏移表,顶点着色器用 gl_InstanceID 选自己的偏移。关键链路只有三步:
// vertex shader
uniform vec2 offsets[100];
void main() {
vec2 offset = offsets[gl_InstanceID];
gl_Position = vec4(aPos + offset, 0.0, 1.0);
fColor = aColor;
}// CPU:生成 10 x 10 偏移并上传 offsets[0..99]
glBindVertexArray(quadVAO);
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, 100);这说明 gl_InstanceID 不是“第几个顶点”,而是第几个四边形实例:6 个顶点共享同一个 ID;下一个四边形的 6 个顶点才切换到下一个 ID。这个 uniform 数组版本适合学习和少量实例,后面才用 VBO 解决容量上限。
第二条路没有这个 uniform 上限,叫 ↡一种「每个实例一份」的顶点属性:把每实例的数据(比如每个实例的偏移向量、或整个变换矩阵)放进一个 VBO,像普通顶点属性那样用 glVertexAttribPointer 设好,再用 glVertexAttribDivisor(loc, 1) 告诉 GPU「这个属性每个实例才往下读一条、不是每个顶点」。它不受 vertex uniform 容量限制,但仍受 VBO 内存、顶点属性槽和带宽约束。(instanced array)。思路是把「每实例的数据」(每个实例的偏移、甚至整个变换矩阵)放进一个 VBO,像普通顶点属性那样在着色器里 in 进来。但这里有个关键差别要交代清楚:普通顶点属性是每个顶点读一条,而实例化数组要的是每个实例才读一条。这个「多久读一条」的开关,就是下一节的主角。
核心难点:glVertexAttribDivisor —— 逐顶点还是逐实例
实例化里最该一次搞懂的,就是 ↡glVertexAttribDivisor(loc, divisor) 设置某个顶点属性「多久往下读一条数据」:divisor=0(默认)是逐顶点步进——着色器每处理一个顶点,就读这个属性的下一条;divisor=1 是每实例步进——着色器每开始画一个新实例,才读下一条,同一个实例内的所有顶点共用同一条。实例化数组必须设 divisor=1,否则它会被当成普通逐顶点属性、每个实例的数据全乱。divisor=n 则每 n 个实例读一条。。函数是 glVertexAttribDivisor(location, divisor),它给某个顶点属性设一个「步进频率」:
divisor = 0(默认):逐顶点步进。着色器每处理一个顶点,就往下读这个属性的下一条。普通的位置、法线、纹理坐标都是这样——每个顶点各有各的。divisor = 1:每实例步进。着色器只有每开始画一个新实例时,才往下读一条;同一个实例内部的所有顶点,共用同一条。
实例化数组要的正是 divisor = 1——一个实例的所有顶点应该共享同一份「这个实例的偏移 / 矩阵」。下面这张图把两种步进并排掰碎,是本章最该盯着看的一张:
glVertexAttribDivisor(loc, 0) 逐顶点(每个顶点读一条)vs (loc, 1) 每实例(一条覆盖整个实例所有顶点)。实例属性忘设 1=被当逐顶点读、所有实例叠一起。一句话记住:普通属性逐顶点读(divisor=0),实例化数组逐实例读(divisor=1)。忘了把实例化数组设成 divisor=1,它就会被当成逐顶点属性,每个实例的偏移全错——这是本章头号大坑(§7 第一个)。
行星带:把整个变换矩阵当实例化属性
把上面这套用到极致,就是原书的 ↡实例化的经典案例:让上千上万颗小行星绕一颗行星排成一圈飞行。每颗小行星是同一个网格(同一份几何),但各有不同的位置、缩放、朝向,于是给每个实例准备一个 4×4 变换矩阵 mat4 作为实例化数组属性,一次 draw call 画出整条带子。因为 mat4 太大,一个顶点属性位置只能放一个 vec4,所以它要占用 4 个连续的属性位置(location)、各设 divisor=1。(asteroid field):上千上万颗小行星绕着一颗行星排成一圈飞。每颗小行星是同一个网格,但位置、缩放、朝向各不相同——这正好是「整个变换矩阵每实例一份」。于是给每个实例准备一个 mat4 变换矩阵,作为实例化数组属性。只是 mat4 太大、一个属性位置只能装一个 vec4,所以它要占用 4 个连续的属性位置,每个都设 divisor=1(§6 第三段代码就是这么做的)。本章 Demo 画的就是这样一条程序化的行星带——你能亲手把它从一百颗拖到一万颗。
动手:把实例数从 100 拖到 10000
猜一猜:下面这块画布里是一整条「行星带」,成百上千颗小行星全是同一个程序化小立方体。现在拖动「实例数」滑块,把它从 100 一路拉到 10000:哪一项成本被保持为常数,哪一项仍在增长?先拖一下试试,再读解释。
这块画布严格对应本章核心:所有小行星由一个 <instancedMesh> 画出(three.js 的实例化网格,底层就是 gl.drawElementsInstanced 一次 draw call)。同一份立方体几何只存一份,每颗小行星的位置 / 缩放 / 朝向由一份「每实例变换矩阵」决定,颜色按实例编号程序化生成——没有任何外部模型或贴图。拖动滑块改的只是「画多少个实例」(mesh.count),不重建任何几何:
实例化「行星带」演示加载中…
拖到 10000 时,这个演示仍维持一个实例化网格,因此右上角的示意提交数恒为 1。作为对比,旁边标着「不实例化需要的次数」会随滑块一路涨到一万:如果真的一颗一颗画,CPU 要发起一万次 draw call。这里的画布刻意只有一个网格和材质,用来隔离“提交次数”这个变量;真实模型有多个子网格 / 材质时,实例化提交数是每个批次一次,而不是全场景永远一次。GPU 的顶点、片段、带宽和过绘仍随实例数增加,是否卡顿要看设备和 profiler。
单步走一遍:divisor 到底改了什么
猜一猜:普通顶点属性(位置、法线)和实例化数组属性(每实例的偏移矩阵),它们读数据的频率一样吗?如果把实例化数组当成普通属性那样「逐顶点读」,会发生什么?单步走一遍就明白了。
下面把 glVertexAttribDivisor 的语义拆成三步,每一步配一张图,盯着「着色器多久才往下读一条属性」:
① divisor=0(默认):每个顶点读一条
普通顶点属性(位置 / 法线 / uv)的步进方式:着色器
每处理一个顶点
,就从属性表里往下读一条。一个网格有 4 个顶点,就读 4
条,一条对一个顶点。这是逐顶点步进,divisor
的默认值就是 0。
走完这遍就清楚了:divisor 只决定一件事——这个属性多久往下读一条。普通属性逐顶点读(0),实例化数组逐实例读(1)。一个数字之差,整片实例排得齐还是乱。
代码逐段拆解
实例化的代码分三块:实例化绘制调用、gl_InstanceID + uniform 数组取偏移、实例化数组 + divisor。三块对照看,C++/OpenGL 与 WebGL2/TS 互为镜像。好消息是 WebGL2 原生支持实例化(gl.drawArraysInstanced / gl.vertexAttribDivisor 等),不像几何着色器那样缺席。
第一块:实例化绘制调用
把普通的 glDrawArrays / glDrawElements 换成带 instanceCount 的实例化版本,一次画 instanceCount 个:
// 普通:一次画一个网格(36 个顶点的立方体)
glDrawArrays(GL_TRIANGLES, 0, 36);
// 实例化:一次画 amount 个(最后多一个 instanceCount 参数)
glDrawArraysInstanced(GL_TRIANGLES, 0, 36, amount);
// 带索引的版本(行星带用这个)
glDrawElementsInstanced(GL_TRIANGLES, indexCount,
GL_UNSIGNED_INT, 0, amount);// 普通:一次画一个网格
gl.drawArrays(gl.TRIANGLES, 0, 36);
// 实例化:一次画 amount 个(最后多一个 instanceCount 参数)
gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, amount);
// 带索引的版本
gl.drawElementsInstanced(gl.TRIANGLES, indexCount, gl.UNSIGNED_INT, 0, amount);就这一行的差别:Instanced 版本末尾多一个 instanceCount(这里是 amount),GPU 就把同一份几何重复画 amount 遍。但光这样画出来的 amount 个会完全重叠——还得告诉每个实例「你摆哪」,这是下面两块的事:
第二块:gl_InstanceID + uniform 数组取偏移(少量实例)
实例少时,最简单的做法是把每实例偏移塞进一个 uniform 数组,顶点着色器用 gl_InstanceID 当下标取出自己那一份:
#version 300 es
layout(location = 0) in vec2 aPos;
uniform vec2 offsets[100]; // 每实例一个偏移,上限受 GPU uniform 容量限制
void main() {
// gl_InstanceID:当前是第几个实例(0,1,2…),拿它取自己那份偏移
vec2 offset = offsets[gl_InstanceID];
gl_Position = vec4(aPos + offset, 0.0, 1.0);
}// CPU 端:把 100 个偏移一次性传进 uniform 数组
const offsets = new Float32Array(100 * 2);
for (let i = 0; i < 100; i++) {
offsets[i * 2 + 0] = /* 第 i 个的 x */ x;
offsets[i * 2 + 1] = /* 第 i 个的 y */ y;
}
gl.uniform2fv(gl.getUniformLocation(program, "offsets"), offsets);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, 100); // 画 100 个这招简单,但 offsets[100] 这个 uniform 数组有大小上限——GPU 的 uniform 容量有限,塞一百个还行,要画一万个就远远不够了。实例一多,就得换成下面的实例化数组:
第三块:实例化数组 + glVertexAttribDivisor(海量实例)
把每实例数据放进 VBO、当顶点属性传,并用 glVertexAttribDivisor(loc, 1) 设成「每实例读一条」。先看最简单的 vec2 偏移:
// 把每实例偏移放进一个 VBO
unsigned int instanceVBO;
glGenBuffers(1, &instanceVBO);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(glm::vec2) * 100,
&translations[0], GL_STATIC_DRAW);
// 像普通属性那样设 location=2
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0);
// 关键:每实例才读一条(不是每顶点)
glVertexAttribDivisor(2, 1);// 把每实例偏移放进一个 VBO
const instanceVBO = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, instanceVBO);
gl.bufferData(gl.ARRAY_BUFFER, translations, gl.STATIC_DRAW);
// 像普通属性那样设 location=2
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 2 * 4, 0);
// 关键:每实例才读一条(不是每顶点)
gl.vertexAttribDivisor(2, 1);顶点着色器把它当普通 in 属性收:layout(location = 2) in vec2 aOffset;,然后 gl_Position = vec4(aPos + aOffset, 0.0, 1.0);。重点是那句 glVertexAttribDivisor(2, 1)——少了它,aOffset 会被当逐顶点读、整片实例错乱。
行星带要的不是 vec2 偏移、而是整个 mat4 变换矩阵。mat4 一个属性位置装不下(一个属性位最多 vec4),所以它占 4 个连续的属性位置,每个都要单独设 divisor=1:
glBindBuffer(GL_ARRAY_BUFFER, matrixVBO); // 存了 amount 个 mat4
GLsizei vec4Size = sizeof(glm::vec4);
// mat4 = 4 列 vec4,占 location 3、4、5、6 四个属性位
for (int i = 0; i < 4; i++) {
glEnableVertexAttribArray(3 + i);
glVertexAttribPointer(3 + i, 4, GL_FLOAT, GL_FALSE,
4 * vec4Size, (void*)(i * vec4Size));
glVertexAttribDivisor(3 + i, 1); // 4 个位置都要逐实例
}#version 300 es
layout(location = 0) in vec3 aPos;
layout(location = 3) in mat4 instanceMatrix; // 占 location 3,4,5,6
uniform mat4 projection;
uniform mat4 view;
void main() {
// 每个实例用自己那份变换矩阵
gl_Position = projection * view * instanceMatrix * vec4(aPos, 1.0);
}对照看:mat4 instanceMatrix 在着色器里只占一个 layout(location = 3),但 CPU 端要把它拆成 4 个 vec4 属性位(3、4、5、6)逐个 vertexAttribPointer + vertexAttribDivisor(., 1)。配好后每个兼容子网格调用一次 glDrawElementsInstanced(..., amount),就能高效绘制整条行星带——这正是上面 Demo 抽象出的底层做法。
原书的行星带在每颗小行星上预先生成一份 mat4:沿半径为 radius 的圆环平移,再加入有限范围的随机位移、缩放和旋转。上传矩阵 VBO 后,每个 rock 子网格的 VAO都必须记录这四个实例属性;渲染时按子网格循环调用 glDrawElementsInstanced。这既保留模型的多网格结构,也把“每颗小行星一次调用”降为“每种子网格一次调用”。
容易踩的坑
小结
- 实例化 = 一次 draw call 画一个兼容批次中的很多个相同网格:把
glDrawArrays换成glDrawArraysInstanced(..., instanceCount),省的是 CPU 反复发起 draw call 的通信开销,不消除 GPU 的每实例工作 - 让每个实例不一样有两条路:
gl_InstanceID当下标取 uniform 数组偏移(实例少时简单,有大小上限),或实例化数组(每实例一份属性,画海量实例) glVertexAttribDivisor(loc, divisor)是核心:0逐顶点读、1每实例读;实例化数组必须设1,忘了就被当逐顶点、整片错乱- 行星带用
mat4实例化矩阵:一个mat4占 4 个连续属性位,每个都要divisor=1;对每个 rock 子网格各提交一次实例化绘制 - 实例化只能在同一调用内画同一网格 / 材质批次的多份(变换 / 属性可不同、几何不能换)
练习
问题 1(改 Demo 代码) 上面的 InstancingDemo 只有一个网格和材质,所以 10000 个实例仍属于 1 个兼容批次。现在假设有人去掉实例化、改成循环逐个绘制(每颗小行星调一次 gl.drawElements)。① 画 5000 颗时会发起多少次 draw call?② 这种写法优先放大了哪一段成本?③ 若真实小行星模型有 3 个兼容子网格,实例化后最少还要几次 draw call?
问题 2(排错题) 某人把每实例偏移 vec2 放进 VBO、glVertexAttribPointer(2, 2, GL_FLOAT, ...) 设好了 location=2,顶点着色器也写了 layout(location=2) in vec2 aOffset;,然后 glDrawArraysInstanced(GL_TRIANGLES, 0, 6, 100)。结果实例的位置错乱,若 VBO 只准备了 100 条偏移还可能发生越界属性读取。他漏了哪一句?为什么?
问题 3(独立实现题) 你要画一片 1000 棵的草地,每棵草是同一个网格,但位置不同、且各自有不同的随机朝向(绕 Y 轴旋转一个随机角度)。问:① 每棵草的「位置 + 旋转」该用什么数据结构当实例化属性最合适?② 这个数据结构在 CPU 端配置 vertexAttribPointer 时有什么特别之处?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 实例化
一种绘制技术:用一次 draw call 画出一个兼容批次中很多个相同的网格(同一份几何),而不是循环调用很多次。每个实例几何完全一样,只是各自的位置 / 缩放 / 朝向(变换)或某些属性不同。不同网格、材质或不兼容状态仍要分批。常用来画大量重复物体:草地、森林、粒子、行星带。详见本章「实例化」一节。
- draw call 开销
一次 draw call(如
glDrawArrays)背后是一趟「CPU 把绘制命令打包、发给 GPU」的通信,含驱动校验、状态切换和命令提交等成本。当物体小而调用数大时,CPU 反复发起成千上万次 draw call 往往更早成为瓶颈;GPU 的顶点和片段工作仍取决于实际绘制量。实例化把一个兼容批次的 N 次 draw call 压成 1 次,正是省这笔提交成本。详见本章「实例化:一次 draw call 画一整片」一节。- gl_InstanceID
顶点着色器里的一个内置整数,表示「当前画的是第几个实例」,从 0 开始递增(第 0 个实例是 0、第 1 个是 1……),只在实例化绘制时有意义。常拿它当下标去取每实例数据,比如
offsets[gl_InstanceID],给每个实例一个不同偏移。详见本章「怎么让每个实例不一样」一节。- 实例化数组
一种「每个实例一份」的顶点属性:把每实例的数据(偏移向量、或整个变换矩阵)放进一个 VBO,像普通顶点属性那样设好,再用
glVertexAttribDivisor(loc, 1)告诉 GPU「这个属性每个实例才读一条、不是每个顶点」。它不受 vertex uniform 数组容量限制,但仍受 VBO 内存、顶点属性槽和传输带宽约束。详见本章「怎么让每个实例不一样」一节。- 实例化步进 divisor
glVertexAttribDivisor(loc, divisor)设某个顶点属性「多久往下读一条」:divisor=0(默认)是逐顶点——每处理一个顶点就读下一条;divisor=1是每实例——每开始一个新实例才读下一条,同一实例内所有顶点共用一条。实例化数组必须设1,否则被当逐顶点读、整片错乱。详见本章「核心难点:glVertexAttribDivisor」一节。- 行星带
实例化的经典案例:上千上万颗小行星绕一颗行星排成一圈飞。每颗是同一个网格,但位置 / 缩放 / 朝向各异,于是给每个实例一个
mat4变换矩阵当实例化数组属性。每个兼容 rock 子网格各做一次实例化绘制;因为mat4太大,要占 4 个连续属性位置、各设divisor=1。详见本章「行星带」一节。
版本、来源与运行边界
本章以 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 结果的验证。
正式概念与状态责任
- instancing:在“实例化、批次边界与每实例矩阵”中由VAO 的逐顶点/逐实例属性与 instanced draw call负责解释其输入、受控状态和可观察结果;运行时以VAO 属性/divisor 查询、instance buffer 大小、实例 ID、draw 参数与实例变换定位它的第一处变化。
- gldrawarraysinstanced:在“实例化、批次边界与每实例矩阵”中由VAO 的逐顶点/逐实例属性与 instanced draw call负责解释其输入、受控状态和可观察结果;运行时以VAO 属性/divisor 查询、instance buffer 大小、实例 ID、draw 参数与实例变换定位它的第一处变化。
- gl_instanceid:在“实例化、批次边界与每实例矩阵”中由VAO 的逐顶点/逐实例属性与 instanced draw call负责解释其输入、受控状态和可观察结果;运行时以VAO 属性/divisor 查询、instance buffer 大小、实例 ID、draw 参数与实例变换定位它的第一处变化。
- instanced array:在“实例化、批次边界与每实例矩阵”中由VAO 的逐顶点/逐实例属性与 instanced draw call负责解释其输入、受控状态和可观察结果;运行时以VAO 属性/divisor 查询、instance buffer 大小、实例 ID、draw 参数与实例变换定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“上传实例数据,为每列配置 attribute+divisor,再调用 instanced draw”发生后,VAO 的逐顶点/逐实例属性与 instanced draw call应怎样改变instance count、attribute locations、stride/offset、divisor、gl_InstanceID 和矩阵;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
实例化、批次边界与每实例矩阵:状态合同
把共享 mesh 与 per-instance 偏移/mat4 属性分开,并用 divisor 控制推进频率
验证场景
官方教程正式概念
logl-27 · 基线帧
instancing:固定 context、资源内容与输入事件,执行“上传实例数据,为每列配置 attribute+divisor,再调用 instanced draw”
冻结输入:instancing
VAO 的逐顶点/逐实例属性与 instanced draw call记录instance count、attribute locations、stride/offset、divisor、gl_InstanceID 和矩阵
结果:得到可重复的初始 GL 状态与资源身份
观测:VAO 属性/divisor 查询、instance buffer 大小、实例 ID、draw 参数与实例变换中的初始快照
预期:VAO 的逐顶点/逐实例属性与 instanced draw call得到可复查结果,并持续满足“mat4 四列占连续 location 且每列 divisor=1;instance count 不越过缓冲”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“上传实例数据,为每列配置 attribute+divisor,再调用 instanced draw”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“mat4 四列占连续 location 且每列 divisor=1;instance count 不越过缓冲”。
CPU command · GL state · GPU result
实例化、批次边界与每实例矩阵:五段轨迹
当前观测:VAO 属性/divisor 查询、instance buffer 大小、实例 ID、draw 参数与实例变换中的初始快照
不变量:mat4 四列占连续 location 且每列 divisor=1;instance count 不越过缓冲
实验三:单故障与同输入恢复
注入“只给 mat4 第一列设置 divisor,后三列每个顶点推进,实例矩阵被撕裂”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有VAO 属性/divisor 查询、instance buffer 大小、实例 ID、draw 参数与实例变换一起恢复才算修复。
Single fault · first divergence · replay
实例化、批次边界与每实例矩阵:反例与恢复
故障:只给 mat4 第一列设置 divisor,后三列每个顶点推进,实例矩阵被撕裂
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“只给 mat4 第一列设置 divisor,后三列每个顶点推进,实例矩阵被撕裂”
mat4 四列占连续 location 且每列 divisor=1;instance count 不越过缓冲
VAO 属性/divisor 查询、instance buffer 大小、实例 ID、draw 参数与实例变换
最小可重放检查
unit: logl-27
owner: VAO 的逐顶点/逐实例属性与 instanced draw call
state_or_resource: instance count、attribute locations、stride/offset、divisor、gl_InstanceID 和矩阵
command: 上传实例数据,为每列配置 attribute+divisor,再调用 instanced draw
pass_invariant: mat4 四列占连续 location 且每列 divisor=1;instance count 不越过缓冲
single_fault: 只给 mat4 第一列设置 divisor,后三列每个顶点推进,实例矩阵被撕裂
required_evidence: VAO 属性/divisor 查询、instance buffer 大小、实例 ID、draw 参数与实例变换复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。