自由学习记录(117)

"加上 normal map 后",同一点的 Y 分量从正变负?

完全正常的原因:normal map 让法线朝 -B 倾斜,映射到世界/视空间后 Y 变负

如果你显示的是 最终世界法线 nWS(或视空间 nVS),normal map 本来就会把法线从"基底法线 N"扰动到某个方向:

  • nWS = normalize(T * nTS.x + B * nTS.y + N * nTS.z)

只要这一点的扰动结果让 世界/视空间的 y 分量 跨过 0,就会出现 G 从 ≥128 变成 <128。

这不一定是错的------很多表面本来就可能朝世界 -Y。

float3x3(T,B,N) 的行/列语义 + mul 的方向用反了(非常常见)

也就是 T/B/N 作为矩阵的列向量(columns),nTS 是列向量系数。

但在 HLSL 里 float3x3(a,b,c) 初始化时,a/b/c 默认是"行" (row0,row1,row2)。而 mul(M, v) 做的是 "矩阵乘列向量",即每个输出分量是 dot(row_i, v)

所以你现在写的 mul(float3x3(T,B,N), nTS) 实际算的是:

  • x = dot(T, nTS)

  • y = dot(B, nTS)

  • z = dot(N, nTS)

这不是 T*n.x + B*n.y + N*n.z,语义完全不同,结果会像你看到的那样"局部翻转/颜色块"。

正确写法(推荐两个等价版本选一个):

版本 A:显式线性组合(最不容易踩坑)

复制代码

float3 Nmap = normalize(T * nTS.x + B * nTS.y + Ngeo * nTS.z);

版本 B:如果你坚持用矩阵,把列构造正确(需要转置)

复制代码

float3x3 TBN = float3x3(T, B, Ngeo);

float3 Nmap = normalize(mul(transpose(TBN), nTS));

(或者写 mul(nTS, TBN),但这依赖你对行向量/列向量的约定,很容易又绕晕;我建议 A 或 transpose 版本。)

因为法线贴图是单位向量。你放大 x/y 会把长度变大、方向变"斜",必须把 z 重新算成

z = sqrt(1 - x^2 - y^2)

才能把向量重新归一化。这样"强度变大但仍是合法法线",光照才稳定。

这个 shader 里,AO 只影响 IBL 的高光部分

ao = packed.b * _AOIntensity;

然后 color += iblSpec * F * ao;

也就是说它只压暗环境反射,不影响直射光的 diffuse/specular。

对,AO 不是 BRDF 的一部分,是额外的"遮蔽/减弱"项。

它通常用于压暗间接光或环境反射,属于近似的后乘因子。

  • 各向同性版本:D_GGX + Vis_SmithJoint(只用一个 alpha)。
  • 各向异性版本:D_GGX_Aniso + Vis_SmithJoint_Aniso(用 ax/ay 和 T/B)。

你现在用的是各向异性版本。

ax 和 ay 是各向异性粗糙度:

  • ax 控制沿切线 T 方向的高光宽度
  • ay 控制沿副切线 B 方向的高光宽度
    它们让高光从"圆形"变成"椭圆形/拉丝方向"。

高光分布的形状本质上就是由"在两个正交方向上的粗糙度"决定的。

在各向异性 GGX 里,ax/ay 直接进入 NDF 的分母:它决定了 H 在 T/B 方向上的衰减速度,所以会把原本圆形的高光拉伸成椭圆。

所以只要两个参数,就足够控制整个分布形态。

  • D:法线分布函数(NDF),描述微表面法线朝向的统计分布。
  • V(Vis):几何项可见性,描述遮蔽/自遮挡(masking-shadowing)的影响。

几何遮蔽"确实依赖法线分布",但它不是由"朝向分布"决定的,而是由"空间相关性/高度相关性"决定的

Cook--Torrance 把 D 和 V 分开,是在有意丢弃这种相关性,用可积、可实现的方式近似真实世界

如果我不知道遮蔽(masking/shadowing),

那我至少知道微表面的法线分布;

而遮蔽一定和微表面朝向有关;

那为什么不直接从 D 推出 V?

这在真实物理世界里是对的。

如果你有一个完整的微表面几何场(不仅是法线分布,还包括高度、坡度相关性、邻域关系),那么:

  • 哪些微表面被遮住

  • 哪些微表面能看到光/视线

确实可以从同一个统计模型里推出

在这个意义上,你的直觉是"更物理"的。

D 描述的是:

"如果我随机挑一个微表面,它的法线朝向是 h 的概率密度是多少"

注意三个限定词:

  • 随机挑一个

  • 单个微表面

  • 只关心朝向

👉 D 是一个"零空间信息、零邻域信息"的分布。

完全不知道

  • 微表面之间的高度关系

  • 一个微表面前面是否挡着另一个

  • 相邻微表面是否相关(correlation)

从信息论角度说:

D 是一阶统计量(marginal distribution),
遮蔽是高阶统计量(joint / conditional)。

你不能从一阶统计量,唯一推出高阶统计行为。

遮蔽需要什么信息,而 D 没有?

几何遮蔽回答的问题是:

"在给定入射/出射方向下,

一个朝向为 h 的微表面,

是否被其它微表面挡住?"

这至少需要知道:

  • 微表面的高度分布

  • 微表面之间的空间排列

  • 法线与高度之间是否相关

  • 邻域是否独立(这点非常关键)

Cook--Torrance 的 D 明确假设:

微表面是 无限薄、无高度信息、相互独立的"法线样本"

一旦你接受这个假设,遮蔽就无法从 D 推出

那为什么微表面模型"故意"这样拆?

这是一个建模哲学问题,不是物理问题

1️⃣ 如果你不拆 D 和 V,会发生什么?

如果你试图把"遮蔽 + 朝向"揉成一个函数:

  • 你必须引入:

    • 高度分布

    • 法线-高度相关性

    • 邻域统计

  • 结果是:

    • 模型极难解析

    • 积分不可解

    • 参数不可控

    • 实时渲染基本不可能

这在学术上是存在的 (例如 Smith + correlated height fields,Heitz 的工作),

但那是路径追踪/离线渲染领域。

为什么 V 和 D 会"共用粗糙度参数",却仍然分开?

你提到的这个点非常关键,而且你已经注意到了一个"破绽"。

是的:

  • D(α)

  • V(α)

都依赖 roughness α

这说明什么?

说明:

它们不是物理独立的

只是被建模为统计独立,但参数相关

这是一个非常典型的工程折中:

  • 共享参数:保持宏观行为一致(越粗糙 → 越分散、越遮蔽)

  • 函数独立:保持可积性、稳定性、可实现性

Smith / GGX 的 V 函数,本质上就是:

"在假设微表面独立、随机、无限延展的前提下,

对遮蔽效应的一个可解析近似"

  • 共享参数 :保持宏观行为一致(越粗糙 → 越分散、越遮蔽

  • 函数独立 :保持可积性、稳定性、可实现性

Smith / GGX 的 V 函数,本质上就是:

"在假设微表面独立、随机、无限延展的前提下,

对遮蔽效应的一个可解析近似"

  • 法线分布只告诉你"有多少表面是倾斜的"

  • 遮蔽还取决于:

    • 倾斜表面是否在前面

    • 是否挡住了后面的表面

    • 挡住的是光还是视线

两个具有相同 D 的微表面场,
可以有完全不同的遮蔽行为。

不理解,会被认真讨论的问题?如果是真的 那说明我现在考虑的东西过度了?

最终积分对象是同一个 BRDF (无论你从半球视角还是从微表面法线视角)。

D 和 V 并不会"分别积分到不同东西上",它们是 BRDF 的因子,最终一起出现在同一个积分里。

原版 Eevee 的材质节点系统拿不到、也不允许你在材质里做那类全局采样/多次采样的输入。

为什么原版 Blender(尤其 Eevee)材质里做不到同样的东西

原版 Blender 的节点系统在 Eevee 下是一个"受限的材质图",它通常只能访问:

  • 当前片元插值得到的几何属性(UV、Normal、Tangent、Position 等)

  • 贴图采样

  • 少量内置着色信息(如 Fresnel、Layer Weight、Light Path/Cycles 专用等)

但它不能在材质节点里"随意"做到下面这些(这正是 Curvature 需要的):

1)访问并采样 GBuffer(深度/法线缓冲)做邻域卷积

屏幕空间曲率=对屏幕邻域采样深度/法线并做差分,这是典型后处理/屏幕空间 pass 的工作;原版 Eevee 的材质节点不提供"Scene Depth/Scene Normal"这类可被任意采样的输入(至少不是以这种节点形式开放给材质图)。

2)在材质节点里做可控的多采样循环(Samples=4000 这种)

标准材质图不提供"循环采样 + 累积"的表达能力(更别提性能和确定性)。Goo Engine 作为 fork 可以在引擎侧实现一个固定功能节点:内部用自定义 shader 代码做 N 次采样,而不是让用户任意写循环。

3)把"场景信息"以节点形式喂给材质(Scene Rim / Scene Curvature)

这通常需要改 Eevee 的渲染管线:增加额外 buffer 或额外 pass,然后把结果作为纹理输入给材质系统。原版 Blender 出于一致性/复杂度/性能/维护成本,并不会轻易把这种"全局后处理信号"直接塞进材质节点域。

原版 Blender 能不能"实现类似效果"

能,但路径不同,而且没有 Goo Engine 这么"一颗节点解决"。

你在原版 Blender 里实现类似 Curvature/Rim/Cavity,通常走三种路线:

1)几何域曲率(真正的曲率/凹凸),不是屏幕空间

  • Geometry Nodes 里有 Curvature 类的计算(或用邻域法线变化近似),输出成 attribute,再在材质里读 attribute。

    优点:与分辨率无关、稳定;缺点:它是"网格曲率",不是屏幕空间的"轮廓/接触"感觉,且对拓扑/细分敏感。

2)Cycles 的 Pointiness / Bevel 等技巧(偏离线渲染)

  • Cycles 有 "Pointiness"(基于微分几何/邻域)能做 cavity。

    优点:效果好;缺点:Eevee 不等价,且不是同一渲染路径。

3)后处理(Compositor / Screen Space pass)

  • 用 Z、Normal Pass(在支持的情况下)在合成器里做 edge detect/curvature 类处理。

    优点:符合屏幕空间本质;缺点:不是"材质节点内实时反馈",也不方便做 per-material 控制。

Goo Engine 的价值就在于:它把本来应属于"渲染管线/后处理"的信号,用一个节点封装进材质域,并给了 Samples/Thickness 这种"算法参数",所以看起来像"原生材质能力变强了"。

SamplesSample AOThickness,这几乎就是在暗示它内部做了类似下面的事(伪代码):

  1. 屏幕空间邻域采样(典型做法)
  • 取当前像素的 scene depth / scene normal(来自 GBuffer)

  • 在屏幕上围绕当前像素取 N 个偏移点(N=Samples 或与之相关)

  • 对每个偏移点再取 depth/normal

  • 计算差异(深度差、法线差、遮蔽量)

  • 把这些差异累计成一个"curvature / rim / cavity"信号

  • 原版材质节点更像"无循环的表达式图"(把一堆函数组合起来)

  • 它没有给你一个"for 循环节点",也没有让你在节点层面表达"重复采样 N 次并累积"

  • 更重要的是:Eevee 的材质节点通常也拿不到 SceneDepth/SceneNormal 这种"场景缓冲"作为可多次采样的输入

先统一"Vis 应该长什么样"(物理与工程直觉)

以最常见的 Smith G₂(或其 visibility 形式) 为例:

  • Vis/G 的物理含义:
    给定 N,L,VN, L, VN,L,V,有多少微表面在"没被遮挡/没被自遮蔽"的情况下参与反射。

  • 关键单调性(这点非常重要):

    • 当 N⋅V→0(视线掠射)→ Vis 应该减小

    • 当 N⋅L→0(光线掠射)→ Vis 应该减小

    • 当表面在背光 (N⋅L≤0)→ Vis 应该为 0 或被强烈抑制

结论:

背光面几乎全白(≈1)在"几何可见性"的意义下是错误的。

问题出在 Vis 公式本身是"只在 NoL>0、NoV>0 的前提下成立"

你现在直接把 Vis = G/(4 NoL NoV) 当可视化输出用,且 NoL/NoV 被 saturate 成 0 后又在分母里,导致 除以接近 0 → 爆白

所以不是物理单调性错了,而是 使用方式缺少 gating

  • NoL<=0 或 NoV<=0 时应直接 Vis=0(或至少不计算)。
  • 或在可视化时乘 step(NoL,0) / step(NoV,0) 抑制背光面。

一句话:Vis 的定义域被你违反了

这类 if (NoV<=0 || NoL<=0) return 0; 在 GPU 上通常是净收益或近似无影响 ,尤其在你这段 Vis 里后面还有 sqrt、除法、各向异性 lambda 之类的重活。

正确说法不是"shader 里不要写 if",而是:

不要写"会造成严重 warp/wave 分歧、且无法省掉昂贵计算"的 if。

现代 GPU 上 if 到底"贵不贵"

1️⃣ GPU 真正怕的不是 if,而是 divergence

GPU 以 warp / wave(N 个线程一起跑) 执行:

  • 如果 warp 内所有线程条件一致

    几乎零成本分支

  • 如果一半进 if,一半进 else

    → 两条路径都要跑(串行化)

所以问题不是"写不写 if",而是:

这个 if 在空间上是不是 coherent(一致的)


2️⃣ 你这个 if 的条件是高度 coherent 的

你的条件是:

复制代码

if (NoV <= 0 || NoL <= 0)

这意味着什么?

  • NoL <= 0 → 整片背光面

  • NoV <= 0 → 整片背视面

这些在屏幕上是:

  • 连续区域

  • 大块三角形

  • 高空间一致性

👉 warp 内绝大多数线程会走同一分支

这是 GPU 最喜欢的 if 类型。


三、更关键的一点:你这个 if 是"早退 + 省重算"

这点非常重要,很多"不要写 if"的建议忽略了这一类 if

你这个 if 的位置在:

  • 两个 Lambda_GGX_Aniso

  • sqrt

  • 除法

  • 各向异性计算

这些都是高成本操作。

你的 if 做了什么?

复制代码

if (NoV <= 0 || NoL <= 0) return 0;

这意味着:

  • 在背光 / 背视区域

  • 直接跳过所有昂贵计算

即使存在一点点 divergence,省掉的算力也几乎一定 > 分支成本

👉 这类 if 在现代 shader 里是正收益模式


四、什么时候"真的不该写 if"

为了对齐你的理解边界,我给你一个清晰的反例对照:

❌ 不好的 if(该避免)

复制代码

if (noise(uv) > 0.5) doExpensiveA(); else doExpensiveB();

问题:

  • 条件是高频噪声

  • warp 内线程分歧极大

  • 两边都要算

  • 还省不了算力

这才是那句"不要写 if"真正指的情况。


五、为什么很多教程还在强调"用 step/lerp"

因为他们在教的是:

  • 通用安全写法

  • 不要求你理解 GPU 执行模型

  • 不需要判断 coherence

而你现在已经在做的是:

  • 物理模型调试

  • 可见性 gating

  • 各向异性 BRDF

你已经过了"只讲 step/lerp"的阶段。

こうみゃく

只要分支一致性不是特别差,早退 if 往往净收益

  1. 被跳过的工作量:后面算得越重,if 越值得

    你这段 Vis 后面有:

  • 两次 lambda(含 sqrt/除法)

  • 1/(1+...)

  • 再除以 4 NoL NoV

属于"算术重 + 可能触发数值不稳"的类型。只要分支一致性不是特别差,早退 if 往往净收益

反过来,如果后面只是几条 MAD(乘加),那分支就不值。

更合理的做法是把缩放放在最终高光项(specular *= k)

需要。金属度流程里:

  • 非金属用固定 0.04
  • 金属用 albedo 作为 F0(金属的反射色)

所以 lerp(0.04, albedo, metallic) 是标准写法。

只有在 specular workflow 或你有单独的 specular 贴图时,才不从 albedo 取。

albedo 和 BRDF 根本不是同一个量

这是大多数混淆的源头。

albedo 是什么?

  • 无量纲

  • 定义:

    入射到表面的总能量中,有多少比例被反射出去

  • 典型范围:[0, 1]

它是一个**"总量比例"**,不带方向。

lbedo 要除以 π,是因为 albedo 是"反射率",而 BRDF 是"每单位立体角的反射密度"

对于理想漫反射体(Lambertian),只有用 albedo / π 作为 BRDF,半球积分后的总反射能量才等于 albedo

为什么 π 只出现在 diffuse,不出现在 specular?

这是一个非常好的延伸问题。

  • Lambert diffuse:

    • 分布是常数

    • π 是积分常数 → 必须手动除

  • Microfacet specular:

    • D、F、G 已经构造为可积且能量守恒

    • π 被"隐含"在 D 的归一化里了(例如 GGX 的 D 已经满足 ∫D(h)cosθ dh = 1)

所以你在 specular BRDF 里不再显式看到 π,但它并没有消失。

  • albedo 是"反射总比例",不是 BRDF

  • BRDF 是"方向分布密度",单位是 1/sr

  • Lambertian 的分布是常数

  • 半球 cosθ 积分 = π

  • 为了让总反射 = albedo,BRDF 必须 = albedo / π

下面是基于常见 PBR/Toon 管线的"合理猜测":

directLighting_diffuse 的输入通常对应这些语义:

  • shadowRampColor:从 ramp 采样出来的"阴影/明暗分段色"(或明暗权重),相当于 stylized diffuse 形状控制。
  • directOcclusion:直射光的遮蔽因子(AO/自遮挡/接触阴影),用来压暗直射 diffuse。
  • diffuseColor:材质的基础漫反射色(albedo/基色)。
  • 可能还有 lightColor / NoL / shadow(如果这个 group 内部再算一次光照因子)。

输出相当于:

directDiffuse = diffuseColor * shadowRampColor * directOcclusion * (lightColor or NoL)

具体细节取决于 group 内是否还乘光色/NoL。

NoL 是连续渐变的 Lambert 权重,只能当"临时替代"。

真正的 ramp 是对 NoL 做分段/曲线重映射(比如贴图或阈值),才能有卡通分段效果。

皮肤背光太暗,根因不是 diffuse 算错,而是 真实皮肤存在"透射/散射/多次能量回填",而标准 BRDF 把这些全部忽略了

工程上通常通过 wrap diffuse、SSS/近似 SSS、背光补能项 来解决,而不是硬改 albedo 或 NoL。

皮肤的真实结构本身是各向异性的

真实皮肤至少有三层和一个方向性结构:

  • 表皮 / 真皮

    • 胶原纤维(collagen fibers)有方向性分布

    • 这在医学上叫 Langer lines(皮纹方向)

  • 次表层血管/组织

    • 散射并非完全各向同性
  • 毛孔、微皱纹

    • 在局部尺度上明显有方向性

所以在严格物理意义上:

  • 皮肤的 微表面几何

  • 皮肤的 次表层散射路径

都不是各向同性的。

那为什么大多数皮肤 BRDF/SSS 都当它是各向同性?

因为尺度问题 + 感知问题 + 成本问题

1️⃣ 在"像素级别",方向性被平均掉了

在实时渲染中:

  • 一个像素覆盖:

    • 成百上千个毛孔

    • 无数微皱纹

  • 这些方向在像素内 高度混合

👉 在宏观 BRDF 层面:

  • 各向异性信号被统计平均

  • 剩下的主要是:

    • roughness

    • fresnel

    • N·L / N·V 行为

所以用 isotropic microfacet,感知上已经非常接近。

真正"主导视觉"的不是 BRDF 各向异性

对皮肤来说,决定观感的优先级是

  1. SSS / 次表层散射

  2. 背光能量回填

  3. Roughness 的空间变化

  4. Specular lobe 的宽度/能量

  5. 最后才是:BRDF 各向异性

这也是为什么:

  • Disney / UE / Unity HDRP 的 skin

  • 都是 isotropic specular + SSS

  • 而不是 anisotropic specular

什么时候皮肤的"各向异性"值得被显式考虑?

有,而且是非常具体的几种情况

1️⃣ 拉伸皮肤(stretch / tension)

比如:

  • 关节弯曲

  • 脸部表情拉伸(脸颊、嘴角)

这时:

  • 胶原纤维沿拉伸方向排列

  • 高光在拉伸方向略微拉长

在高质量角色里,有时会看到:

  • roughness 随肌肉拉伸变化

  • 很少直接用 anisotropic BRDF

"泛光(Bloom)"严格来说分两层:

  1. 材质的 emission:把某些像素输出成"很亮的辐亮度值"(HDR 强度)

  2. Bloom:对这些高亮像素做屏幕空间的阈值提取 + 模糊扩散 + 叠加回去

所以结论是:

你可以在 shader 里把 emission 做得很亮,但"光晕扩散到周围像素"的那一部分,本质是跨像素卷积,单个材质的片元 shader 做不了(除非走屏幕空间采样/额外 pass,本质仍是后处理)。

把 emission 拉到 50 但看不到 bloom,是因为:

  • 项目没开 HDR(URP/HDRP Camera 或 pipeline)

  • Bloom 后处理没开

  • 或 tone mapping 把亮度压扁了

Emission 只是写入 HDR 颜色值
Bloom 是后处理在 HDR buffer 上做的亮度提取 + 模糊 + 回加

shader 里你只能:

  • 输出一个 数值大于 1 的颜色

而"泛光":

  • 永远发生在 shader 之后

  • 永远是 全屏后处理

一个非常重要的误区(很多人卡在这)

"我在 shader 里把颜色写得很亮,为什么没 bloom?"

检查顺序必须是:

  1. Camera 是否开启 HDR

  2. 是否真的启用了 Bloom 后处理

  3. Threshold 是否低于你输出的亮度

  4. Tonemapping 是否在 Bloom 之后

  5. 颜色空间是否 Linear

只要其中一个不满足,emission 再亮也不会泛光

如果满足下面三点,他的担心就是合理的,而且已经到了该重构的阶段

  1. 新效果只能靠"再加一个 strength"

  2. 你很难删掉某一项而不影响整体

  3. 你无法用一句话说清楚"哪一项负责什么视觉信号"

一般不会把"光方向"写进 GBuffer。

原因很简单:GBuffer 的定位是"每像素的可重建材质属性"(normal、roughness、albedo、material flags...),而"一个常驻的光方向"本质是全局/每帧级别的数据(甚至就是主方向光的 direction),放进 GBuffer 等于把同一份向量复制到屏幕每个像素里,纯带宽浪费;并且它在 deferred lighting pass 里本来就可用(作为 uniform/CBV/Root Constant 传进去)。

这里确实不是 GBuffer 的问题,而是"数据作用域(scope)"的问题 。把 constant buffer / per-object data / GBuffer 的边界理清,逻辑就非常干净。

我先给结论,再把每一层的逻辑拆开。

结论先行

  • 光方向永远不该进 GBuffer

  • 这个"只对眉毛生效的独立光方向",要么是 per-material / per-object 常量 ,要么是 一个从场景对象(空物体)更新的参数 ,通过 constant buffer / structured buffer 送进 shader。

  • GBuffer 只回答"这个像素是什么",不回答"世界现在发生了什么"。

相关推荐
今天只学一颗糖14 小时前
1、《深入理解计算机系统》--计算机系统介绍
linux·笔记·学习·系统架构
testpassportcn15 小时前
AWS DOP-C02 認證完整解析|AWS DevOps Engineer Professional 考試
网络·学习·改行学it
游乐码18 小时前
c#变长关键字和参数默认值
学习·c#
饭碗、碗碗香19 小时前
【Python学习笔记】:Python的hashlib算法简明指南:选型、场景与示例
笔记·python·学习
魔力军20 小时前
Rust学习Day4: 所有权、引用和切片介绍
开发语言·学习·rust
wubba lubba dub dub75020 小时前
第三十六周 学习周报
学习
学编程的闹钟20 小时前
PHP字符串表示方式全解析
学习
Lbs_gemini060320 小时前
01-01-01 C++编程知识 C++入门 工具安装
c语言·开发语言·c++·学习·算法
饭碗、碗碗香21 小时前
【Python学习笔记】:Python 加密算法全景指南:原理、对比与工程化选型
笔记·python·学习
麟听科技21 小时前
HarmonyOS 6.0+ APP智能种植监测系统开发实战:农业传感器联动与AI种植指导落地
人工智能·分布式·学习·华为·harmonyos