OpenGL实例化渲染进阶

1.1 背景

​   可以把实例化渲染(Instanced Rendering) 理解成:你要画很多“长得一样的东西”(比如1000个立方体),如果用传统方法,CPU需要一遍一遍告诉GPU“再画一个”,每下一次这种命令就叫一次draw call(绘制调用),次数多了CPU就会很忙、性能下降;

for (int i = 0; i < 1000; ++i)
{
    SetShader(shader);              // 设置材质(shader)
    SetUniform("model", matrix[i]); // 每个cube的位置/旋转
    BindVAO(cubeVAO);               // 绑定顶点数据
    glDrawElements(...);            // 一次 draw call
}

​   而实例化渲染是只发一次draw call,然后告诉GPU:“这个模型你给我画1000次,每次用不同的位置、颜色等数据”。GPU会在内部自动循环执行绘制,这种“被重复画的每一份”就叫一个实例(instance)

SetShader(shader);
BindVAO(cubeVAO);

// 上传1000个实例数据(位置、颜色等)到 instance VBO
UploadInstanceBuffer(instanceDataArray);

glDrawElementsInstanced(..., 1000); // 一次 draw call

​   为了做到这一点,需要理解几个基础概念:顶点属性(vertex attribute) 就是描述一个顶点的数据,比如位置、颜色、法线等;这些数据通常存放在VBO(Vertex Buffer Object) 里,你可以把VBO当成GPU中的一块数组内存;而VAO(Vertex Array Object) 是用来“记录这些数据该怎么读”的配置对象,所以实例化绘制是需要VAO的。通常会用两个VBO:一个是顶点VBO(存模型本身,所有实例共享),一个是实例VBO(存每个实例自己的数据,比如位置、缩放、颜色);这两个VBO一般都是交错布局(AoS,结构体一条一条排),因为GPU访问时更连续、cache更友好。

​   关键点在于:默认情况下,GPU读取顶点属性是每处理一个顶点就取一条数据,这就是“每个attribute每个顶点变一次”;但实例化渲染需要另一种节奏:每个实例才变一次。这就是 glVertexAttribDivisor 的作用——当你写 glVertexAttribDivisor(loc, 1) 时,意思是“这个属性在画完一个实例之前都保持不变,只在下一个实例时才更新”,这样GPU在画一个立方体的36个顶点时都会用同一份实例数据,等到下一个立方体才换数据,这就是GPU实现“自动复制但每份有不同参数”的方式。

关键API
    glVertexAttribDivisor(index, divisor);
注:`glVertexAttribDivisor` 用于修改在单次绘制调用中渲染多个图元实例时,通用顶点属性的推进速率。
如果 `divisor` 为零,则位于槽位 `index` 的属性按每个顶点推进一次;如果 `divisor` 不为零,则该属性按每 `divisor` 个被渲染的顶点集合实例推进一次。
若某属性的 `GL_VERTEX_ATTRIB_ARRAY_DIVISOR` 值不为零,则称其为实例化属性(instanced attribute)。`index` 必须小于 `GL_MAX_VERTEX_ATTRIBS` 的值。

​   而实例化数组(Instanced Array) 就是“放在实例VBO里的那一整组按实例排列的数据”,GPU会按实例索引去取。这个技术的优点是:大幅减少draw call(CPU轻松)、批量绘制效率高、适合大量重复物体(树、粒子、线段等);局限是:所有实例必须共享同一套几何结构和着色方式(也就是“相同材质”,指用同一个shader、同一套纹理/参数),如果每个物体的顶点结构都不一样,就不能直接用实例化,只能分批或不用实例化。总结一句话就是:实例化渲染=一份模型数据 + 一组实例数据 + 一次draw call,让GPU自己批量“复制并绘制”。

1.2 实例化图元类

​   InstancePrimitive 是一个面向实例化渲染(instanced rendering)的通用几何封装类,以最少的 draw call 高效绘制大量实例,并在易用性(模板化属性描述)与性能(批量上传、最小化重分配)之间取得平衡。

​   InstancePrimitive类在构造函数中配置 VAO + 顶点VBO +(可选)EBO + 实例VBO,将顶点属性与实例属性统一绑定到同一 VAO。首先创建并绑定 VAO 作为状态容器;随后创建顶点 VBO,将输入的顶点数据上传到 GPU,并按 AttrT::vertexAttrs 描述逐项启用并设置顶点属性布局;如果启用了索引(Indexed=true 且有数据),则创建 EBO、上传索引并绑定到当前 VAO;接着创建实例 VBO(初始为空的动态缓冲),按照 AttrT::instanceAttrs 配置实例属性,并通过 glVertexAttribDivisor(…,1) 标记为按实例更新;最后解绑 VAO。整个流程的目的就是把顶点数据 + 索引数据 + 实例数据布局一次性固定到 VAO 中,从而在后续渲染时只需绑定 VAO 并发起 instanced draw 即可高效绘制。

​   当CPU数据更新完成后,需要调用SyncInstanceBuffer函数完成GPU上传。首先检查 m_instanceDirty,若无改动则直接返回;否则读取当前实例数量,若为 0 则仅清除脏标记退出;接着绑定实例 VBO,并根据实例数量与当前 m_instanceCapacity 对比,若容量不足则按 2 倍增长策略重新 glBufferData 分配更大的 GPU 缓冲(只分配不上传数据),更新容量;随后通过 glBufferSubData 将 CPU 侧 m_instances 连续内存一次性拷贝到 GPU(从 offset=0 覆盖已有数据);最后清除 m_instanceDirty。整个过程保证:只有在实例数据变化时才上传,且通过扩容避免频繁重分配,同时用一次批量拷贝完成同步

1.3 实例化多段线几何类

​   InstanceLineGeometry 的核心由两个成员变量构成:m_primitive(一个 InstancePrimitive<InstanceLineAttr, false>,负责 VAO/VBO/实例缓冲及绘制数据管理)和 m_fAccumLength(CPU 端累计长度,用于保证多段线虚线连续);其数据布局由 InstanceLineAttr 定义,其中 VertexData 仅包含一个单位空间的 position(描述 [0,1] 方向和宽度方向的 quad 顶点),而 InstanceData 则描述每条线段实例的真实信息(pointA/pointB 端点、colorpattern 虚线参数以及 uStart 中的累计长度和线宽),二者通过 vertexAttrs(per-vertex)与 instanceAttrs(per-instance,配合 divisor=1)在 VAO 中绑定,从而实现“单位几何 + 实例数据驱动”的协作;类的主要函数包括:构造时 CreateUnitQuad() 初始化一个固定的 quad 网格,AddSegment() 将输入线段转换为一条 instance(同时计算长度并维护连续虚线参数),SyncInstances() 将所有实例批量上传 GPU,GetLineStyle() 提供不同线型配置,以及若干查询接口供渲染系统使用,整体使用流程是“添加线段 → 同步实例 → 统一绘制”;其设计特点在于通过 instancing 将所有线段统一为一个基础几何进行批量渲染,避免 CPU 生成实际线网格,把位置、宽度和样式计算下放到 GPU,实现高性能、可扩展且支持连续虚线效果的线渲染方案。

// =========================
// 1. 创建对象
// =========================
std::unique_ptr<InstanceLineGeometry> lines =
    std::make_unique<InstanceLineGeometry>();

// =========================
// 2. 添加线段
// =========================
float red[4]   = {1, 0, 0, 1};
float green[4] = {0, 1, 0, 1};
float blue[4]  = {0, 0, 1, 1};

// 一条实线
float a0[3] = {0, 0, 0};
float b0[3] = {1, 0, 0};
lines->AddSegment(a0, b0, red, 0.05f, LineType::SOLID);

// 一条虚线(连续)
float a1[3] = {1, 0, 0};
float b1[3] = {2, 1, 0};
lines->AddSegment(a1, b1, green, 0.05f, LineType::DASH);

// 一条点划线
float a2[3] = {2, 1, 0};
float b2[3] = {3, 1, 0};
lines->AddSegment(a2, b2, blue, 0.05f, LineType::DASH_DOT);

// =========================
// 3. 同步到 GPU
// =========================
lines->SyncInstances();

// =========================
// 4. 渲染
// =========================
glBindVertexArray(lines->GetVao());

// 非 indexed
glDrawArraysInstanced(
    GL_TRIANGLES,
    0,
    lines->GetVertexCount(),     // = 6(unit quad)
    lines->GetInstanceCount()    // 线段数量
);

​   要实现有宽度的多段线实例化渲染,shader中算法是关键。这套线段渲染把每一条线抽象为“单位矩形 + 实例数据”的 instancing 模型,其中顶点数据只是一组规范化参数(沿线方向参数 t∈[0,1],横向偏移 s∈[-0.5,0.5]),而实例数据定义每条线的实际属性:两端点 A、B(世界空间)、颜色、线宽(以像素为单位的屏幕空间宽度)、以及虚线参数(若干 dash/gap 长度,通常也是屏幕空间长度)和一条“累计起始长度”(保证多段线连续);渲染时每个实例用同一个矩形模板,在 GPU 中先把 A、B 通过视图-投影矩阵变换到裁剪空间并透视除法得到屏幕坐标,然后计算屏幕空间方向向量并取其垂直方向得到法线(即对方向做 90° 旋转并归一化),再用 s×线宽 在该法线方向做偏移、用 t 在 A→B 上插值位置,从而生成一个始终以屏幕像素为基准宽度的带状几何(避免透视导致粗细变化);同时根据 t 和线段长度构造一个沿线的累计坐标 U(= 起始长度 + 当前段长度×t),在片元阶段对 U 按虚线周期(dash+gap)做取模判断,决定当前像素是否显示,从而实现虚线/点划线,且由于使用累计长度,跨多段线也能连续;

​   其关键点在于:① 用 instancing 将多条线合并为一次绘制,② 在线段投影到屏幕后计算法线并做偏移实现稳定线宽,③ 虚线间隔通常定义在屏幕空间以保证视觉一致,④ 用累计长度保证虚线在折线中不断裂,⑤ 几何生成与样式计算完全在 GPU 中完成,CPU 只提交端点和样式参数。

1.4 实例化Marker几何类

​   InstanceMarkerGeometry类封装了实例化渲染如点、十字线、交叉线、方块等类型的Marker的函数接口和成员变量。核心成员变量为m_primitive(一个 InstancePrimitive<InstanceMarkerAttr, false>,其中InstanceMarkerAttr结构体定义了VertexData,它包含顶点position属性,定义了实例数据InstanceData,它包含了transform、type、color顶点属性。InstanceMarkerGeometry构造时调用CreateUnitQuad函数创建Triangles布局的单位平面顶点数据,然后构造m_primitive对象。

​   使用时首先通过调用AddMarker将新实例数据添加到InstanceMarkerGeometry类对象中,然后调用SyncInstances将实例数据上传到GPU中,最后绑定InstanceMarkerGeometry对象的VAO实现实例化绘制。

​   Marker的实例化绘制主要有两种方式。第一种是将各种类型的Marker绘制到一张纹理上,然后在shader中根据Marker Type计算合适的纹理坐标采样这样的大纹理,显示到屏幕中。第二种方式不需要纹理,直接在单位矩形上绘制各种sdf形状,优点是矢量绘制,可以控制Marker的形状参数,缺点是shader比较复杂。

​   第二种方案实践中遇到几个问题:

​     a. 如何在相机旋转和缩放时,保持Marker显示效果不变,因为Marker是屏幕空间的

​     对于每个实例的每个顶点,输入是这个实例的中心位置 iPos.xyz(世界坐标)、这个顶点在单位方块里的局部坐标 aPos.xy(范围 -0.5~0.5)、屏幕尺寸 screenSize,以及期望的像素大小 pixelSize;首先用视图矩阵和投影矩阵把中心点 iPos.xyz 从世界空间变换到裁剪空间,再除以 w 得到它在屏幕上的标准化位置(NDC),这一步只决定“这个 marker 在屏幕的哪个位置”;然后把这个 NDC 位置换算成真实的屏幕像素坐标并对齐到像素中心,保证稳定;接下来处理大小——把顶点的局部坐标 aPos.xy 乘以 pixelSize,得到“这个顶点相对于中心点应该偏移多少个像素”,再除以 screenSize 转回 NDC 偏移量,加到刚才的中心点位置上;最后再把这个加完偏移的 NDC 坐标乘回 w 写入 gl_Position 作为输出。因此,每个 instance 的所有顶点都是“同一个屏幕中心点 + 不同的像素偏移”,中心点随相机变化,而偏移始终按像素计算,不参与相机变换,所以最终看到的是:位置跟着场景走,但大小始终是固定像素、不受缩放和旋转影响。

​     b. 如何避免像素抖动,sdf绘制的形状有像素的偏差

​     c. sdf如何实现

​     不同 SDF 只是换了一种“点到边界距离”的计算方式,得到 d 之后,用 d 的大小和符号就能统一控制实心或描边的绘制。对任意一个点 p,算出它到某个图形边界的最短距离 d(内部为负、边界为 0、外部为正),只是不同图形的“距离公式”不一样——例如圆最简单,sdCircle(p,r)=length(p)-r,就是“点到圆心的距离减半径”;矩形用 sdBox(p,b):先算 d=abs(p)-b,外部距离是 length(max(d,0))(到最近边/角的欧氏距离),内部则是 min(max(d.x,d.y),0)(表示离哪条边最近的负距离),两者相加得到完整 SDF;菱形可以看成“L1 范数”的边界,常见写法是 sdDiamond(p,s)=(abs(p.x)+abs(p.y)-s)/√2;等边三角形的 sdTriangle 本质是把点折叠到对称区域后,计算到三条边的最小距离(代码里的变换就是在做这个对称映射,最后 -length(p)*sign(p.y) 给出带符号距离);而旋转函数 rot() 只是把坐标先旋转再丢进同一个距离公式,从而复用同一个形状;最后 fill(d)stroke(d,w) 并不是新的几何,而是根据这个距离值决定显示方式——fill 判断 d≤0(内部区域),stroke 判断 |d|≤w(靠近边界的一圈)。

float stroke(float d, float w)
{
    return step(abs(d), w);
}

float fill(float d)
{
    return step(d, 0.0);
}

mat2 rot(float a)
{
    float s = sin(a), c = cos(a);
    return mat2(c, -s, s, c);
}

// =========================
// SDF
// =========================
float sdCircle(vec2 p, float r)
{
    return length(p) - r;
}

float sdBox(vec2 p, vec2 b)
{
    vec2 d = abs(p) - b;
    return length(max(d,0.0)) + min(max(d.x,d.y),0.0);
}

float sdDiamond(vec2 p, float s)
{
    p = abs(p);
    return (p.x + p.y - s) * 0.7071;
}

float sdTriangle(vec2 p)
{
    const float k = 1.7320508;
    p.x = abs(p.x) - 1.0;
    p.y = p.y + 1.0/k;
    if (p.x + k*p.y > 0.0)
        p = vec2(p.x - k*p.y, -k*p.x - p.y)/2.0;
    p.x -= clamp(p.x, -2.0, 0.0);
    return -length(p)*sign(p.y);
}

// =========================
// Marker (branchless)
// =========================
float marker(vec2 p, float type)
{
    float scale = 0.25;
    p /= scale;

    // =========================
    // 类型 mask(无分支)
    // =========================
    float w0 = step(abs(type - 0.0), 0.1);
    float w1 = step(abs(type - 1.0), 0.1);
    float w2 = step(abs(type - 2.0), 0.1);
    float w3 = step(abs(type - 3.0), 0.1);
    float w4 = step(abs(type - 4.0), 0.1);
    float w5 = step(abs(type - 5.0), 0.1);
    float w6 = step(abs(type - 6.0), 0.1);
    float w7 = step(abs(type - 7.0), 0.1);
    float w8 = step(abs(type - 8.0), 0.1);

    // =========================
    // 每种 shape
    // =========================
    float d0 = fill(sdCircle(p, 0.2));

    float d1 = fill(min(
        sdBox(p, vec2(0.08, 0.6)),
        sdBox(p, vec2(0.6, 0.08))
    ));

    float d2 = fill(min(
        sdBox(p, vec2(0.06, 0.7)),
        min(
            sdBox(rot(1.0472)*p, vec2(0.06, 0.7)),
            sdBox(rot(-1.0472)*p, vec2(0.06, 0.7))
        )
    ));

    float d3 = stroke(sdCircle(p, 0.6), 0.05);

    float d4 = fill(min(
        sdBox(rot(0.7854)*p, vec2(0.06, 0.7)),
        sdBox(rot(-0.7854)*p, vec2(0.06, 0.7))
    ));

    float d5 = stroke(sdTriangle(p*0.8), 0.05);

    float d6 = stroke(sdTriangle(vec2(p.x, -p.y)*0.8), 0.05);

    float d7 = stroke(sdBox(p, vec2(0.5)), 0.05);

    float d8 = stroke(sdDiamond(p, 0.8), 0.05);

    // =========================
    // 无分支选择
    // =========================
    return
        w0*d0 +
        w1*d1 +
        w2*d2 +
        w3*d3 +
        w4*d4 +
        w5*d5 +
        w6*d6 +
        w7*d7 +
        w8*d8;
}
posted @ 2026-05-15 18:36  王小于的啦  阅读(38)  评论(0)    收藏  举报