Horse3D 游戏引擎研发笔记(四):光照与 Shader 预编译——从 material 到 GPU UBO
Horse3D 游戏引擎研发笔记四光照与 Shader 预编译——从 .material 到 GPU UBOBilibili 同步视频一、为什么需要预编译和 UBO二、整体流程三、.material 中的光照声明四、Shader 预处理器4.1 递归 include4.2 占位符替换五、编译、链接与缓存六、三块 UBO 的内存布局6.1 FrameBlock6.2 LightBlock6.3 PhongMaterialBlock七、LightingBinder把场景光推给 GPU八、Phong 光照模型九、设计取舍十、当前成果十一、下一步项目仓库目标梳理 Horse3D 中多光源渲染的完整链路包括.material配置的 Shader 预处理、编译缓存、Uniform Block ObjectUBO组织以及 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 光照计算整个链路可以概括为编译构建预处理磁盘资源Program Uniforms运行时binding 0 / 1LightingBinder上传 UBOPhong Shader采样 UBO 计算光照Phong.materialphong.vertphong.fragLighting.glslFrameBlock.glslShaderPreprocessor展开 #includeShaderPreprocessor替换 {{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在编译前传入实际数字constShaderPreprocessor::Definitions definitions{{QStringLiteral(DIRECTIONAL_LIGHT_COUNT),QString::number(directionalLightCount)},{QStringLiteral(POINT_LIGHT_COUNT),QString::number(pointLightCount)},{QStringLiteral(SPOT_LIGHT_COUNT),QString::number(spotLightCount)}};source.secondShaderPreprocessor::process(source.second,definitions);替换完成后预处理器还会检查残留占位符避免把不合法的 GLSL 交给驱动编译。五、编译、链接与缓存预处理完成后MaterialBuilder::compileAndLink使用QOpenGLShaderProgram完成 GPU 编译auto*programnewQOpenGLShaderProgram();for(constautosource:sources){auto*shadernewQOpenGLShader(source.first,program);if(!shader-compileSourceCode(source.second)){qWarning(MaterialBuilder: shader compile error:\n%s,qPrintable(shader-log()));deleteprogram;returnnullptr;}program-addShader(shader);}if(!program-link()){...}为了避免同一材质、同一光源配置重复编译MaterialBuilder用(materialPath, directionalCount, pointCount, spotCount)作为缓存键QString keyQFileInfo(materialPath).canonicalFilePath();keyQStringLiteral(|lights:%1,%2,%3).arg(directionalLightCount).arg(pointLightCount).arg(spotLightCount);if(m_cache.contains(key))returnm_cache.value(key);这样场景中有 1 方向光 4 点光源时会生成一份独立的 Program切回 1 方向光 1 点光源时如果之前编译过就直接复用。六、三块 UBO 的内存布局Horse3D 把整个渲染需要用到的常量数据分成三个 Uniform BlockBindingBlock 名称内容更新频率0FrameBlockview、projection、camera position、ambient color/intensity每帧一次1LightBlockdirectional / point / spot 光源数组每帧一次2PhongMaterialBlockbaseColor、diffuseStrength、specularStrength、shininess材质绑定时6.1 FrameBlocklayout(std140, binding 0) uniform FrameBlock { mat4 u_view; mat4 u_projection; vec4 u_cameraPosition; vec4 u_ambientColorIntensity; };C 端对应LightingBinder::FrameDatastructalignas(16)FrameData{floatview[16];floatprojection[16];QVector4D cameraPosition;QVector4D ambientColorIntensity;};6.2 LightBlockstruct 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::vectorGpuDirectionalLight等结构体按std140对齐顺序打包成一块QByteArray然后一次性上传。6.3 PhongMaterialBlocklayout(std140, binding 2) uniform PhongMaterialBlock { vec4 u_baseColor; float u_diffuseStrength; float u_specularStrength; float u_shininess; };注意std140布局要求vec4后紧跟的标量会填充到vec4边界因此 C 端必须显式补paddingstructalignas(16)PhongMaterialData{QVector4D baseColor;floatdiffuseStrength;floatspecularStrength;floatshininess;floatpadding;};static_assert(sizeof(PhongMaterialData)32,Phong material data must match the std140 block layout);七、LightingBinder把场景光推给 GPULightingBinder是连接场景与 Shader 的桥梁。每帧渲染 Pass 开始时调用beginPassvoidLightingBinder::beginPass(constScenarioscenario,constCameracamera,quint32 lightMask)执行步骤把camera.viewMatrix()、camera.projectionMatrix()、相机位置、环境光写入FrameData遍历scenario.lights()根据lightMask和各类光源容量筛选有效光源将光源数组顺序拷贝到一块连续的QByteArray调用glBufferSubData更新 UBO并绑定到 binding 0 和 1。auto*glBrush::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必须区分光照传输两个 UBOFrame 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 模型替换 PhongUniformBlock 材质参数体系Shader 热重载监听.material/.frag/.vert变化并重新编译MaterialBuilder 缓存IBL 环境光照基于 CubeMap 的漫反射与镜面反射TextureCube LightBlock项目仓库Giteehttps://gitee.com/shendeyidi/softwarer-horse本系列记录 Horse3D 游戏引擎从零开始的研发过程欢迎交流。