光照相关逻辑
需要预先了解:
1. 光照相关逻辑
这篇笔记梳理 IDGameEngine 里所有和光照有关的逻辑:光源在 CPU 侧的封装与上传、前向与延迟两条渲染路径的组织,以及 Phong / Blinn-Phong / PBR 三种光照模型在 shader 里的实现。光照代码分布在前向的 geometry.fsl 和延迟的 deferred_lighting.fsl 两份 shader 里,两者公式逐段一致——任一路径的画面都可以作为另一条的验收基准。
1.1 光源的封装
引擎里一个光源就是一个 Light 结构体:
enum class LightType : uint8_t
{
Directional = 0, // 平行光
Point, // 点光源
Spot, // 聚光灯
};
struct Light : public SerializableBase
{
LightType type = LightType::Directional;
Vec3 color = Vec3(1.0f, 1.0f, 1.0f); // 光源颜色
float intensity = 1.0f; // 光源强度
union
{
Vec3 direction = Vec3(1.0f, -1.0f, 0.0f); // 平行光:照射方向
Pos3 position; // 点光源 / 聚光灯:光源位置
} drop; // d or p 的通假
float inner_cone_angle = 15.0f; // 聚光灯内锥角(度)
float outer_cone_angle = 30.0f; // 聚光灯外锥角(度)
bool enabled = true;
};
drop 是一个匿名联合体:平行光只用方向、点光源和聚光灯只用位置,两者共用一份存储。场景里挂着 LightComponent 的 GameObject 会被 Renderer::render 自动收集,逐个 submit 进帧队列(LightSE),再统一塞进 RenderContext::lights。
上传到 shader
光源数组由 RenderPass::set_frame_uniforms 逐个上传,shader 侧是三组平行数组:
uniform int u_light_count;
uniform vec3 u_light_dirs[8]; // 平行光方向
uniform vec3 u_light_positions[8]; // 点/聚光灯位置
uniform vec3 u_light_colors[8]; // 光色(已含强度)
上传时有两个细节值得注意:
// 光色为 sRGB 空间用户输入:先 pow 线性化再乘 intensity
// (shader 侧 pow 会把 intensity 一起 gamma 掉)
Vec3 color{
std::pow(light.color[0], 2.2f) * light.intensity,
std::pow(light.color[1], 2.2f) * light.intensity,
std::pow(light.color[2], 2.2f) * light.intensity };
线性化必须在 CPU 侧先做——如果只把原始颜色传上去、在 shader 里 pow(color * intensity, 2.2),intensity 会被一起 gamma 掉,调亮光源时手感会明显不对。另外方向和位置按类型互斥填充:平行光填 u_light_dirs、位置填零,点光源反过来。shader 侧靠"哪个非零"来区分光源类型,省掉一个 type 数组。
超过上限 8 个的光源直接丢弃并告警(前向路径的限制,后面延迟路径会放宽到 32)。
1.2 前向渲染
前向路径由 RenderPass与RenderGraph 里的 ForwardPass 承担:对每个物体,一次性完成"采样材质 + 计算全部光照 + 输出颜色"。geometry.fsl 的主循环结构:
vec3 result = ambient; // 环境光打底
for (int i = 0; i < u_light_count; ++i)
{
vec3 L = /* 光方向(平行光取 -dir,点光取 pos - world_pos) */;
vec3 diffuse = calc_diffuse(...); // 漫反射
vec3 specular = /* Phong / Blinn-Phong / PBR 三选一 */;
float shadow = /* 阴影系数,仅主方向光 */;
result += diffuse * base_color * shadow + specular;
}
FragColor = vec4(result, 1.0);
前向的优点是流程直接、材质信息随手可用;缺点也明显:光照计算次数 = 物体数 × 光源数,而且被深度测试挡住的片元,光照算完才发现白算了。光源一多,成本线性上涨——这就是 8 个光源上限的由来。
1.3 延迟渲染
延迟路径把"几何"和"光照"拆成两个 Pass,对应 RenderPass与RenderGraph 图里的 GBufferPass → LightingPass。
GBufferPass:几何阶段
GBufferPass 把不透明物体以 MRT(Multiple Render Targets,一次片元着色输出多个附件)的形式写进 G-Buffer,完全不算光照:
| 附件 | 格式 | xyz / rgb | w / a |
|---|---|---|---|
| RT0 | RGBA8 | Albedo(漫反射颜色) | ambient_strength |
| RT1 | RGBA16F | WorldPos(世界坐标) | spec_strength |
| RT2 | RGBA16F | Normal(世界空间法线) | shininess |
gbuffer.fsl 本体非常短,就是把顶点着色器传下来的数据打包进三个输出:
layout(location = 0) out vec4 GBuffer0;
layout(location = 1) out vec4 GBuffer1;
layout(location = 2) out vec4 GBuffer2;
void main()
{
vec4 tex_color = texture(texture_sampler, v_uv);
vec3 albedo = tex_color.rgb * pow(u_color, vec3(2.2)); // sRGB 线性化
GBuffer0 = vec4(albedo, u_ambient_strength);
GBuffer1 = vec4(v_world_pos, u_spec_strength);
GBuffer2 = vec4(normalize(v_normal), u_shininess);
}
这里有个管线层的小设计:G-Buffer 阶段用的不是材质自己的 shader,而是 resolve_pipeline 派生的管线——同 layout、同 state,换成 gbuffer shader(强制 blend = false、depth_write = true)。材质参数照样能设置,因为 gbuffer.fsl 里的 uniform 名字与材质 shader 保持同名(u_color / texture_sampler / …)。
LightingPass:光照阶段
LightingPass 画一个覆盖全屏的三角形,逐像素采样 G-Buffer 重组出"这个像素是什么材质、在哪、朝向哪",然后对全部光源做光照:
vec4 albedo_ambient = texture(u_gbuffer_albedo, v_uv); // RT0
vec4 pos_spec = texture(u_gbuffer_pos, v_uv); // RT1
vec4 normal_shin = texture(u_gbuffer_normal, v_uv); // RT2
// ... 重组出 albedo / world_pos / normal / 各材质参数
之后的循环与前向的 geometry.fsl 完全一致(同样的环境光、同样的三模型分派、同样的阴影计算),只是输入从顶点插值换成了 G-Buffer 采样。光源上限也提升到了 32。
两阶段拆分的收益:光照计算次数 = 屏幕像素数 × 光源数,与场景物体数无关。被挡住的片元在几何阶段就被深度测试剔掉了,光照阶段只在最终可见的像素上花功夫。代价是 G-Buffer 吃显存带宽、透明物体没法写 G-Buffer(所以延迟装配下透明物体仍走 TransparentPass 的前向混合)、多种材质类型难以共存(当前全场景统一一套 Blinn-Phong/PBR 参数)。
注:延迟路径还有一步"深度搬运"——LightingPass 开头把 G-Buffer 的深度 blit 到场景 FBO,这样后面的 SkyboxPass(深度 LessEqual)和 TransparentPass(深度测试)才有正确的深度可用。
1.4 光照模型
shader 侧支持三种光照模型,由 RendererSettings::lighting_model 全局切换(uniform u_lighting_model:0 = Phong,1 = Blinn-Phong,2 = PBR)。
环境光与漫反射
环境光模拟"四面八方弹过来的间接光",简单到只有一行:
vec3 calc_ambient(float strength, vec3 light_color)
{
return strength * light_color;
}
漫反射(Lambert)按入射角的余弦衰减——表面正对光源最亮、平行于光线则为零:
vec3 calc_diffuse(vec3 normal, vec3 light_dir, float kd, vec3 light_color)
{
float diff = max(dot(normal, light_dir), 0.0);
return diff * kd * light_color;
}
Phong 与 Blinn-Phong
镜面反射高光,两种经典的算法都在:
// Phong:视线与"反射光"的夹角
vec3 calc_spec_phong(vec3 normal, vec3 light_dir, vec3 view_dir,
float ks, float shininess, vec3 light_color)
{
vec3 reflect_dir = reflect(-light_dir, normal);
float spec = pow(max(dot(view_dir, reflect_dir), 0.0), shininess);
return spec * ks * light_color;
}
// Blinn-Phong:法线与"半程向量"的夹角
vec3 calc_spec_blinn_phong(vec3 normal, vec3 light_dir, vec3 view_dir,
float ks, float shininess, vec3 light_color)
{
vec3 half_dir = normalize(light_dir + view_dir);
float spec = pow(max(dot(normal, half_dir), 0.0), shininess);
return spec * ks * light_color;
}
区别在参考向量:Phong 拿视线和镜面反射方向比,Blinn-Phong 拿表面法线和半程向量(half vector,视线与光线方向的角平分向量)比。后者在掠射角(视线几乎平行于表面)时高光形状更自然,且省掉一次 reflect,是引擎的默认选择。
PBR
PBR(Physically Based Rendering,基于物理的渲染)模式走 Cook-Torrance 反射方程的直接光照简化版,金属度-粗糙度工作流。单光源的贡献:
三项依次是微平面法线分布函数 D、几何遮蔽函数 G、菲涅尔项 F:
D(GGX / Trowbridge-Reitz 分布):描述微表面法线朝向半程向量的比例,粗糙度越高分布越宽:
float distribution_ggx(vec3 N, vec3 H, float roughness)
{
float a = roughness * roughness;
float a2 = a * a;
float NdotH = max(dot(N, H), 0.0);
float denom = (NdotH * NdotH * (a2 - 1.0) + 1.0);
denom = 3.14159265 * denom * denom;
return a2 / max(denom, 1e-5);
}
G(Smith + Schlick-GGX):微表面互相遮挡、遮蔽光线的比例,分视线和光线两侧各算一次:
float geometry_schlick_ggx(float NdotX, float roughness)
{
float k = (roughness + 1.0) * (roughness + 1.0) / 8.0; // 直接光照 k 近似
return NdotX / (NdotX * (1.0 - k) + k);
}
float geometry_smith(vec3 N, vec3 V, vec3 L, float roughness)
{
return geometry_schlick_ggx(max(dot(N, V), 0.0), roughness)
* geometry_schlick_ggx(max(dot(N, L), 0.0), roughness);
}
F(Schlick 菲涅尔近似):视角越掠射、反射越强;金属的 F0 基色由 albedo 决定,非金属统一取 0.04:
vec3 fresnel_schlick(float cos_theta, vec3 F0)
{
return F0 + (1.0 - F0) * pow(clamp(1.0 - cos_theta, 0.0, 1.0), 5.0);
}
组合起来:
vec3 calc_light_pbr(vec3 N, vec3 V, vec3 L, vec3 radiance, vec3 albedo,
float metallic, float roughness)
{
vec3 H = normalize(V + L);
float NdotL = max(dot(N, L), 0.0);
vec3 F0 = mix(vec3(0.04), albedo, metallic);
float D = distribution_ggx(N, H, roughness);
float G = geometry_smith(N, V, L, roughness);
vec3 F = fresnel_schlick(max(dot(H, V), 0.0), F0);
vec3 numerator = D * G * F;
float denominator = 4.0 * max(dot(N, V), 0.0) * NdotL + 1e-5;
vec3 specular = numerator / denominator;
vec3 kd = (vec3(1.0) - F) * (1.0 - metallic); // 金属几乎无漫反射
vec3 diffuse = kd * albedo / 3.14159265;
return (diffuse + specular) * radiance * NdotL;
}
metallic / roughness 目前是全局 uniform(RendererSettings 里两个滑条),还没做成逐材质参数——引擎当前阶段的 PBR 更多是"管线打通",离美术可用还有距离。
1.5 颜色空间约定
光照计算必须在线性空间进行,否则平方衰减、余弦定律这些物理式全会被 gamma 扭曲。引擎的约定是"输入 sRGB,计算线性,输出时再转回去":
- 用户输入的材质颜色
u_color、环境光u_ambient、光色:在进光照前pow(x, 2.2)线性化(光色在 CPU 侧线性化,见上文); - 纹理:创建时用
SRGB8_ALPHA8内部格式,采样时硬件自动解码,shader 无需处理; - 输出:场景渲染目标是线性 RGBA16F(HDR),ToneMapping 和 gamma 校正推迟到后处理一起做(见 PostProcess相关逻辑)。
2. 小结
光照逻辑的版图:
- 封装:
Light结构体(联合体存方向/位置)→ LightComponent → 帧队列 → 平行数组 uniform; - 路径:前向(ForwardPass,物体 × 光源,上限 8)/ 延迟(GBufferPass + LightingPass,像素 × 光源,上限 32),两路径 shader 公式逐段一致;
- 模型:Phong / Blinn-Phong(默认)/ PBR(Cook-Torrance,GGX + Smith + Schlick),全局开关切换。
阴影作为光照的乘法因子嵌在这套循环里,展开见 阴影相关逻辑。



