需要预先了解:


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 反射方程的直接光照简化版,金属度-粗糙度工作流。单光源的贡献:

Lo=(kd⋅albedoπ+D⋅G⋅F4 (N⋅V)(N⋅L))⋅Li⋅(N⋅L)L_o = \Big( k_d \cdot \frac{albedo}{\pi} + \frac{D \cdot G \cdot F}{4 \, (N \cdot V)(N \cdot L)} \Big) \cdot L_i \cdot (N \cdot L)

三项依次是微平面法线分布函数 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);
}

DGGX(N,H,α)=α2π((N⋅H)2(α2−1)+1)2D_{GGX}(N, H, \alpha) = \frac{\alpha^2}{\pi \big( (N \cdot H)^2 (\alpha^2 - 1) + 1 \big)^2}

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),全局开关切换。

阴影作为光照的乘法因子嵌在这套循环里,展开见 阴影相关逻辑。