云计算百科
云计算领域专业知识百科平台

Horse3D 游戏引擎研发笔记(四):光照与 Shader 预编译——从 material 到 GPU UBO

Horse3D 游戏引擎研发笔记(四):光照与 Shader 预编译——从 `.material` 到 GPU UBO

  • Bilibili 同步视频
  • 一、为什么需要预编译和 UBO
  • 二、整体流程
  • 三、`.material` 中的光照声明
  • 四、Shader 预处理器
    • 4.1 递归 include
    • 4.2 占位符替换
  • 五、编译、链接与缓存
  • 六、三块 UBO 的内存布局
    • 6.1 FrameBlock
    • 6.2 LightBlock
    • 6.3 PhongMaterialBlock
  • 七、LightingBinder:把场景光推给 GPU
  • 八、Phong 光照模型
  • 九、设计取舍
  • 十、当前成果
  • 十一、下一步
  • 项目仓库

目标:梳理 Horse3D 中多光源渲染的完整链路,包括 .material 配置的 Shader 预处理、编译缓存、Uniform Block Object(UBO)组织,以及 Phong 光照模型在 GPU 上的实现。

Bilibili 同步视频

Horse3D 游戏引擎研发笔记(四):光照与 Shader 预编译

一、为什么需要预编译和 UBO

在笔记(二)里,材质系统已经能把 Shader 从 JSON 配置中加载出来。但当场景引入光源后,两个新问题立刻出现:

  • GLSL 中光源数组大小必须是编译期常量。运行时动态决定的光源数量无法直接声明为 uniform Light lights[n],除非用 SSBO 或动态索引扩展。
  • 逐光源逐个 glUniform* 上传效率低。每帧每个光源都要走多次 CPU → GPU 的小数据拷贝,光源一多就形成瓶颈。
  • 因此项目采用两条策略:

    • 预编译:在 Shader 编译前用预处理器把 {{DIRECTIONAL_LIGHT_COUNT}} 等占位符替换成实际数字,为不同光源数量生成独立的 Shader Program 变体。
    • UBO:把每帧变化的帧数据(view/projection/camera/ambient)和光源数据打包成两个 Uniform Block,一次 glBufferSubData 上传,所有物体共享。

    二、整体流程

    从磁盘文件到 GPU 光照计算,整个链路可以概括为:

    #mermaid-svg-uGsxtjvBF3CnlZLa{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uGsxtjvBF3CnlZLa .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uGsxtjvBF3CnlZLa .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uGsxtjvBF3CnlZLa .error-icon{fill:#552222;}#mermaid-svg-uGsxtjvBF3CnlZLa .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uGsxtjvBF3CnlZLa .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uGsxtjvBF3CnlZLa .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uGsxtjvBF3CnlZLa .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uGsxtjvBF3CnlZLa .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uGsxtjvBF3CnlZLa .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uGsxtjvBF3CnlZLa .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uGsxtjvBF3CnlZLa .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uGsxtjvBF3CnlZLa .marker.cross{stroke:#333333;}#mermaid-svg-uGsxtjvBF3CnlZLa svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uGsxtjvBF3CnlZLa p{margin:0;}#mermaid-svg-uGsxtjvBF3CnlZLa .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-uGsxtjvBF3CnlZLa .cluster-label text{fill:#333;}#mermaid-svg-uGsxtjvBF3CnlZLa .cluster-label span{color:#333;}#mermaid-svg-uGsxtjvBF3CnlZLa .cluster-label span p{background-color:transparent;}#mermaid-svg-uGsxtjvBF3CnlZLa .label text,#mermaid-svg-uGsxtjvBF3CnlZLa span{fill:#333;color:#333;}#mermaid-svg-uGsxtjvBF3CnlZLa .node rect,#mermaid-svg-uGsxtjvBF3CnlZLa .node circle,#mermaid-svg-uGsxtjvBF3CnlZLa .node ellipse,#mermaid-svg-uGsxtjvBF3CnlZLa .node polygon,#mermaid-svg-uGsxtjvBF3CnlZLa .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uGsxtjvBF3CnlZLa .rough-node .label text,#mermaid-svg-uGsxtjvBF3CnlZLa .node .label text,#mermaid-svg-uGsxtjvBF3CnlZLa .image-shape .label,#mermaid-svg-uGsxtjvBF3CnlZLa .icon-shape .label{text-anchor:middle;}#mermaid-svg-uGsxtjvBF3CnlZLa .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uGsxtjvBF3CnlZLa .rough-node .label,#mermaid-svg-uGsxtjvBF3CnlZLa .node .label,#mermaid-svg-uGsxtjvBF3CnlZLa .image-shape .label,#mermaid-svg-uGsxtjvBF3CnlZLa .icon-shape .label{text-align:center;}#mermaid-svg-uGsxtjvBF3CnlZLa .node.clickable{cursor:pointer;}#mermaid-svg-uGsxtjvBF3CnlZLa .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uGsxtjvBF3CnlZLa .arrowheadPath{fill:#333333;}#mermaid-svg-uGsxtjvBF3CnlZLa .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uGsxtjvBF3CnlZLa .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uGsxtjvBF3CnlZLa .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uGsxtjvBF3CnlZLa .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uGsxtjvBF3CnlZLa .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uGsxtjvBF3CnlZLa .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uGsxtjvBF3CnlZLa .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uGsxtjvBF3CnlZLa .cluster text{fill:#333;}#mermaid-svg-uGsxtjvBF3CnlZLa .cluster span{color:#333;}#mermaid-svg-uGsxtjvBF3CnlZLa div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uGsxtjvBF3CnlZLa .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uGsxtjvBF3CnlZLa rect.text{fill:none;stroke-width:0;}#mermaid-svg-uGsxtjvBF3CnlZLa .icon-shape,#mermaid-svg-uGsxtjvBF3CnlZLa .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uGsxtjvBF3CnlZLa .icon-shape p,#mermaid-svg-uGsxtjvBF3CnlZLa .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uGsxtjvBF3CnlZLa .icon-shape .label rect,#mermaid-svg-uGsxtjvBF3CnlZLa .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uGsxtjvBF3CnlZLa .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uGsxtjvBF3CnlZLa .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uGsxtjvBF3CnlZLa :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    编译构建

    预处理

    磁盘资源

    Program + Uniforms

    运行时

    binding 0 / 1

    LightingBinder上传 UBO

    Phong Shader采样 UBO 计算光照

    Phong.material

    phong.vert

    phong.frag

    Lighting.glsl

    FrameBlock.glsl

    ShaderPreprocessor展开 #include

    ShaderPreprocessor替换 {{LIGHT_COUNT}}

    MaterialBuilder编译 / 链接 / 缓存

    图 1:光照渲染链路总览。 磁盘上的 .material 与 .vert/.frag 经过预处理后编译为 Program;每帧 LightingBinder 把相机和光源数据写入 UBO,片元着色器直接读取完成光照计算。

    三、.material 中的光照声明

    以 Materials/Phong/Phong.material 为例,除了顶点属性和普通 Uniform 外,还声明了一个 Uniform Block:

    {
    "Attributes": [
    {"Name": "aPos", "Dimension": 3},
    {"Name": "aTexCoord", "Dimension": 2},
    {"Name": "aNormal", "Dimension": 3}
    ],
    "Uniforms": [
    {"Name": "u_baseColor", "Type": "Color", "Value": "#d8dce6"},
    {"Name": "u_diffuseStrength", "Type": "Float", "Value": 1.0},
    {"Name": "u_specularStrength", "Type": "Float", "Value": 0.45},
    {"Name": "u_shininess", "Type": "Float", "Value": 48.0}
    ],
    "UniformBlock": {
    "Name": "PhongMaterialBlock",
    "Binding": 2
    },
    "Shaders": [
    {"ShaderEnum": "Vertex", "SourceFile": "phong.vert"},
    {"ShaderEnum": "Fragment", "SourceFile": "phong.frag"}
    ]
    }

    PhongMaterialBlock 用于在 GPU 端以 std140 布局存放材质参数。C++ 端由 Material::uploadUniformBlock() 维护一块 UBO,保证与 GLSL 端内存布局一致。

    四、Shader 预处理器

    ShaderPreprocessor 是一个纯静态工具类,做两件事:递归展开 #include、替换 {{NAME}} 占位符。

    4.1 递归 include

    支持两种语法:

    #include <FrameBlock.glsl> // 从系统 include 目录查找
    #include "phong.glsl" // 从当前源文件目录查找

    核心逻辑 expandIncludes 会维护一个 includeStack,遇到循环引用立即报错返回。例如 phong.frag 这样写:

    #include <FrameBlock.glsl>
    #include <Lighting.glsl>
    #include "phong.glsl"

    预处理后,这三个文件的内容会被直接内联到 phong.frag 中。

    4.2 占位符替换

    Lighting.glsl 中使用占位符定义光源数组上限:

    #define MAX_DIRECTIONAL_LIGHTS {{DIRECTIONAL_LIGHT_COUNT}}
    #define MAX_POINT_LIGHTS {{POINT_LIGHT_COUNT}}
    #define MAX_SPOT_LIGHTS {{SPOT_LIGHT_COUNT}}

    MaterialBuilder::acquire 在编译前传入实际数字:

    const ShaderPreprocessor::Definitions definitions{
    {QStringLiteral("DIRECTIONAL_LIGHT_COUNT"), QString::number(directionalLightCount)},
    {QStringLiteral("POINT_LIGHT_COUNT"), QString::number(pointLightCount)},
    {QStringLiteral("SPOT_LIGHT_COUNT"), QString::number(spotLightCount)}
    };
    source.second = ShaderPreprocessor::process(source.second, definitions);

    替换完成后,预处理器还会检查残留占位符,避免把不合法的 GLSL 交给驱动编译。

    五、编译、链接与缓存

    预处理完成后,MaterialBuilder::compileAndLink 使用 QOpenGLShaderProgram 完成 GPU 编译:

    auto *program = new QOpenGLShaderProgram();
    for (const auto &source : sources) {
    auto *shader = new QOpenGLShader(source.first, program);
    if (!shader->compileSourceCode(source.second)) {
    qWarning("MaterialBuilder: shader compile error:\\n%s", qPrintable(shader->log()));
    delete program;
    return nullptr;
    }
    program->addShader(shader);
    }
    if (!program->link()) { ... }

    为了避免同一材质、同一光源配置重复编译,MaterialBuilder 用 (materialPath, directionalCount, pointCount, spotCount) 作为缓存键:

    QString key = QFileInfo(materialPath).canonicalFilePath();
    key += QStringLiteral("|lights:%1,%2,%3")
    .arg(directionalLightCount)
    .arg(pointLightCount)
    .arg(spotLightCount);
    if (m_cache.contains(key)) return m_cache.value(key);

    这样场景中有 1 方向光 + 4 点光源时,会生成一份独立的 Program;切回 1 方向光 + 1 点光源时,如果之前编译过就直接复用。

    六、三块 UBO 的内存布局

    Horse3D 把整个渲染需要用到的常量数据分成三个 Uniform Block:

    BindingBlock 名称内容更新频率
    0 FrameBlock view、projection、camera position、ambient color/intensity 每帧一次
    1 LightBlock directional / point / spot 光源数组 每帧一次
    2 PhongMaterialBlock baseColor、diffuseStrength、specularStrength、shininess 材质绑定时

    6.1 FrameBlock

    layout(std140, binding = 0) uniform FrameBlock {
    mat4 u_view;
    mat4 u_projection;
    vec4 u_cameraPosition;
    vec4 u_ambientColorIntensity;
    };

    C++ 端对应 LightingBinder::FrameData:

    struct alignas(16) FrameData {
    float view[16];
    float projection[16];
    QVector4D cameraPosition;
    QVector4D ambientColorIntensity;
    };

    6.2 LightBlock

    struct DirectionalLightData { vec4 direction; vec4 colorIntensity; };
    struct PointLightData { vec4 positionRange; vec4 colorIntensity; };
    struct SpotLightData { vec4 positionRange; vec4 directionInnerCos; vec4 colorIntensity; vec4 coneParameters; };

    layout(std140, binding = 1) uniform LightBlock {
    DirectionalLightData u_directionalLights[MAX_DIRECTIONAL_LIGHTS];
    PointLightData u_pointLights[MAX_POINT_LIGHTS];
    SpotLightData u_spotLights[MAX_SPOT_LIGHTS];
    };

    C++ 端使用 std::vector<GpuDirectionalLight> 等结构体按 std140 对齐顺序打包成一块 QByteArray,然后一次性上传。

    6.3 PhongMaterialBlock

    layout(std140, binding = 2) uniform PhongMaterialBlock {
    vec4 u_baseColor;
    float u_diffuseStrength;
    float u_specularStrength;
    float u_shininess;
    };

    注意 std140 布局要求 vec4 后紧跟的标量会填充到 vec4 边界,因此 C++ 端必须显式补 padding:

    struct alignas(16) PhongMaterialData {
    QVector4D baseColor;
    float diffuseStrength;
    float specularStrength;
    float shininess;
    float padding;
    };
    static_assert(sizeof(PhongMaterialData) == 32, "Phong material data must match the std140 block layout");

    七、LightingBinder:把场景光推给 GPU

    LightingBinder 是连接场景与 Shader 的桥梁。每帧渲染 Pass 开始时调用 beginPass:

    void LightingBinder::beginPass(const Scenario &scenario, const Camera &camera, quint32 lightMask)

    执行步骤:

  • 把 camera.viewMatrix()、camera.projectionMatrix()、相机位置、环境光写入 FrameData;
  • 遍历 scenario.lights(),根据 lightMask 和各类光源容量筛选有效光源;
  • 将光源数组顺序拷贝到一块连续的 QByteArray;
  • 调用 glBufferSubData 更新 UBO,并绑定到 binding 0 和 1。
  • auto *gl = Brush::brush()->gl();
    gl->glBindBuffer(GL_UNIFORM_BUFFER, m_frameUbo);
    gl->glBufferSubData(GL_UNIFORM_BUFFER, 0, sizeof(frame), &frame);
    gl->glBindBufferBase(GL_UNIFORM_BUFFER, 0, m_frameUbo);
    gl->glBindBuffer(GL_UNIFORM_BUFFER, m_lightUbo);
    gl->glBufferSubData(GL_UNIFORM_BUFFER, 0, lightBuffer.size(), lightBuffer.constData());
    gl->glBindBufferBase(GL_UNIFORM_BUFFER, 1, m_lightUbo);

    在绘制具体 Object3D 之前,LightingBinder::bind() 再次确认 UBO 绑定:

    gl->glBindBufferBase(GL_UNIFORM_BUFFER, 0, m_frameUbo);
    gl->glBindBufferBase(GL_UNIFORM_BUFFER, 1, m_lightUbo);

    八、Phong 光照模型

    片元着色器 phong.frag 在得到 UBO 数据后,按经典 Phong 模型叠加各光源贡献:

    vec3 normal = normalize(vNormal);
    vec3 viewDirection = normalize(u_cameraPosition.xyz – vWorldPosition);
    vec3 color = calculateAmbientLight();

    for (int i = 0; i < MAX_DIRECTIONAL_LIGHTS; ++i)
    color += calculateDirectionalLight(u_directionalLights[i], normal, viewDirection);

    for (int i = 0; i < MAX_POINT_LIGHTS; ++i)
    color += calculatePointLight(u_pointLights[i], normal, viewDirection);

    for (int i = 0; i < MAX_SPOT_LIGHTS; ++i)
    color += calculateSpotLight(u_spotLights[i], normal, viewDirection);

    fragColor = vec4(color, u_baseColor.a);

    核心评估函数 evaluatePhong 实现漫反射与 Blinn-Phong 风格的镜面反射:

    vec3 evaluatePhong(vec3 normal, vec3 viewDirection, vec3 lightDirection, vec3 radiance)
    {
    float diffuse = max(dot(normal, lightDirection), 0.0) * u_diffuseStrength;

    float specular = 0.0;
    if (diffuse > 0.0) {
    vec3 reflectionDirection = reflect(-lightDirection, normal);
    specular = pow(max(dot(viewDirection, reflectionDirection), 0.0),
    max(u_shininess, 1.0))
    * u_specularStrength;
    }

    return radiance * (u_baseColor.rgb * diffuse + vec3(specular));
    }

    点光源和聚光灯额外计算距离衰减与锥形裁剪:

    float distanceAttenuation(float distanceToLight, float range)
    {
    float rangeFactor = clamp(1.0 – distanceToLight / range, 0.0, 1.0);
    return rangeFactor * rangeFactor / max(distanceToLight * distanceToLight, 0.01);
    }

    聚光灯通过内外锥角度余弦做 smoothstep 边缘过渡,避免硬边锯齿。

    九、设计取舍

    设计点当前方案替代方案选择理由
    光源数组大小 预编译期通过占位符确定,生成 Shader 变体 运行时 SSBO 动态数组 兼容性好,实现简单;光源数量有限时变体数可控
    缓存键 (materialPath, dirCount, pointCount, spotCount) 只按 path 缓存 不同光照配置需要不同 Program,必须区分
    光照传输 两个 UBO(Frame + Light) 单个超大 UBO 或逐 Uniform 职责清晰,Frame 数据与 Light 数据更新时机不同
    材质参数 单独 UBO binding 2 普通 Uniform 减少绑定切换,后续可扩展为通用材质参数块
    Spot 锥形衰减 smoothstep 内外锥余弦 硬裁剪或指数衰减 边缘柔和,更符合现代渲染习惯
    环境光 放在 FrameBlock 中 单独 Uniform 环境光是全局属性,与相机数据同属每帧常量

    十、当前成果

    • .material 支持声明 UniformBlock,材质参数可通过 UBO 上传。
    • ShaderPreprocessor 实现 #include 展开与 {{LIGHT_COUNT}} 占位符替换。
    • MaterialBuilder 按光照配置缓存 Shader Program 变体,避免重复编译。
    • LightingBinder 用两个 UBO 批量传输帧数据与光源数据。
    • 片元着色器实现方向光、点光源、聚光灯的 Phong 光照叠加。

    十一、下一步

    方向目标依赖基础
    SSBO 光源 光源数量动态化,彻底消除 Shader 变体 当前 UBO + 预编译体系
    Shadow Map 为方向光/聚光灯生成深度贴图 多 Pass 管线 + 多相机
    PBR 材质 用 Metallic-Roughness 模型替换 Phong UniformBlock 材质参数体系
    Shader 热重载 监听 .material / .frag / .vert 变化并重新编译 MaterialBuilder 缓存
    IBL 环境光照 基于 CubeMap 的漫反射与镜面反射 TextureCube + LightBlock

    项目仓库

    • Gitee:https://gitee.com/shendeyidi/softwarer-horse

    Horse3D 游戏引擎研发笔记(四):光照与 Shader 预编译——从 .material 到 GPU UBO

    本系列记录 Horse3D 游戏引擎从零开始的研发过程,欢迎交流。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Horse3D 游戏引擎研发笔记(四):光照与 Shader 预编译——从 material 到 GPU UBO
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!