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 配置中加载出来。但当场景引入光源后,两个新问题立刻出现:
因此项目采用两条策略:
- 预编译:在 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:
| 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)
执行步骤:
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 游戏引擎从零开始的研发过程,欢迎交流。
网硕互联帮助中心




评论前必须登录!
注册