公司动态

Shader颜色相加为何可以超过1

📅 2026/8/7 6:50:10
Shader颜色相加为何可以超过1
在自定义光照Shader中我们经常会写出这样的公式FinalColor 主光漫反射 主光高光 额外灯光 环境光 自定义光照 自发光;初学者看到这段代码时往往会产生一个非常自然的问题颜色分量不是通常都在0到1之间吗这么多光照结果一直相加FinalColor会不会超过1如果超过了画面会不会出错或者变成纯白色答案是会超过而且不仅会超过很多情况下“超过1”恰恰是正常且必要的。是否会出问题不取决于Shader中颜色有没有超过1而取决于渲染管线最终如何保存、处理和显示这些颜色。要理解这个问题需要先改变一个常见认知Shader中的颜色并不一定是显示器最终显示的“0到1颜色”它更多时候是一种代表光能量、亮度和反射结果的中间数值。一、颜色不是一桶只能装满1升的水先来看一个非常简单的例子。假设某个像素的材质颜色是白色float3 albedo float3(1.0, 1.0, 1.0);场景中有一盏白色方向光其漫反射计算结果是float3 mainDiffuse float3(0.8, 0.8, 0.8);环境光再提供float3 ambient float3(0.2, 0.2, 0.2);那么float3 finalColor mainDiffuse ambient;得到float3(1.0, 1.0, 1.0);这还没有超过1。但如果这时再加上一盏点光源float3 pointLight float3(0.5, 0.5, 0.5);那么结果变成float3 finalColor float3(1.5, 1.5, 1.5);如果又叠加一个自发光效果float3 emission float3(2.0, 0.5, 0.0);最终结果可能是float3 finalColor float3(3.5, 2.0, 1.5);这显然已经远远超过了0到1的范围。但是它并不意味着计算错误。可以把颜色数值想象成灯光照度而不是油漆颜色。一面白墙本身是白色并不代表它在任何情况下都只能显示(1,1,1)。在阴天时它可能显得灰暗在阳光下它更亮当太阳、补光灯和反射光同时照射时它会非常耀眼。Shader中大于1的数值表达的正是这种“比标准白更亮”的状态。二、float3本身没有0到1的限制在Shader中最常见的颜色类型是float3 color;或者half3 color;无论是float还是half本质上都是浮点数类型。它们可以保存0.0 0.5 1.0 2.0 10.0 100.0甚至更高的数值。例如float3 colorA float3(3.0, 0.0, 0.0); float3 colorB float3(0.0, 5.0, 0.0); float3 colorC colorA colorB;结果为float3(3.0, 5.0, 0.0);GPU不会因为颜色大于1就自动报错也不会因为数值是5就停止计算。所以从Shader数学运算角度看FinalColor 1.0是完全允许的。真正的关键在于这个FinalColor后面写入了什么格式的Render Target以及在写入屏幕前是否经历了HDR、Bloom、曝光和Tone Mapping等处理。三、LDR模式下超过1的颜色往往会被“截断”如果项目使用的是传统LDR渲染也就是低动态范围渲染那么最终颜色通常只能保存在类似8位颜色缓冲中。最常见的格式可以理解为R0 ~ 255 G0 ~ 255 B0 ~ 255换算到Shader中的浮点表示就是0.0 ~ 1.0假如Shader输出return float4(2.0, 1.5, 0.8, 1.0);而最终目标纹理只能保存0到1那么它可能会被截断为float4(1.0, 1.0, 0.8, 1.0);也可以理解为saturate(color);效果类似float3 finalColor saturate(float3(2.0, 1.5, 0.8));结果float3(1.0, 1.0, 0.8);这就是为什么在LDR模式下多个灯光叠加时画面容易出现“死白”现象。例如一个角色的金属盔甲同时受到太阳光点光源聚光灯环境光技能特效光自发光贴图影响时亮部数值很容易超过1。若直接被截断所有很亮的区域都会变成同样的白色细节会丢失。原本应该存在区别的亮度1.2 2.0 5.0 10.0在LDR输出后都可能变成1.0这就像摄影时曝光过度。窗外天空、白色墙壁和灯泡全部白成一片无法分辨谁更亮。四、HDR模式下超过1的颜色会被保留下来HDRHigh Dynamic Range意思是高动态范围。在HDR渲染中Unity会使用能够保存大于1颜色值的浮点Render Texture例如常见的R11G11B10 RGBAHalf RGBAFloat其中RGBAHalf通常使用16位浮点数RGBAFloat使用32位浮点数它们都可以保存明显大于1的数值。此时Shader输出float3 finalColor float3(5.0, 2.0, 0.5);不会立刻被压缩成float3(1.0, 1.0, 0.5);而是先完整保存float3(5.0, 2.0, 0.5);这意味着渲染管线仍然知道红色通道非常亮绿色通道也很亮蓝色较弱这是一个偏橙红色的强光区域。这种保留高亮信息的能力非常重要。例如一个正常白墙可能是float3(0.7, 0.7, 0.7);一个阳光照亮的白墙可能是float3(2.0, 1.9, 1.7);一个灯泡中心可能是float3(20.0, 15.0, 8.0);一个魔法爆炸核心可能是float3(50.0, 10.0, 1.0);在HDR缓冲中这些亮度差异都能保留下来。五、最终显示器只能显示0到1那HDR颜色怎么办这里又会出现第二个问题即使HDR缓冲能保存10、20、50这样的值普通显示器最终不还是只能显示有限亮度吗是的。绝大多数普通显示器最终呈现的颜色范围仍然有限。于是在把HDR颜色输出到屏幕之前Unity通常会进行一个非常关键的步骤Tone Mapping色调映射。色调映射的作用是把很大的亮度范围压缩到显示器能够显示的范围内。例如Shader中有这些HDR亮度0.1 0.5 1.0 2.0 5.0 10.0 50.0经过色调映射后可能变成0.08 0.3 0.5 0.67 0.83 0.91 0.98注意亮度虽然被压缩了但相对关系仍然存在50仍然比10亮10仍然比5亮5仍然比2亮2仍然比普通白色更亮。这就是HDR加色调映射的意义不是简单粗暴地把所有大于1的值切成1而是尽量保留明暗层次。常见色调映射算法包括Reinhard Neutral ACES AgX在Unity的后处理或URP Volume中常见的是Tonemapping → Neutral → ACES其中ACES非常常用它可以让高亮区域逐渐向白色过渡同时尽量保留色彩层次画面更接近电影摄影的观感。六、Bloom为什么依赖“超过1”的颜色Bloom也就是泛光效果是HDR最直观的应用之一。当一个物体特别亮例如太阳灯泡霓虹灯火焰激光魔法球电弧发光UI未来城市广告牌我们希望它不仅自身很亮还会向周围扩散出朦胧的光晕。Bloom通常会先从画面中提取高亮区域。例如设置Bloom阈值Threshold 1.0那么颜色大于1的部分才可能被提取出来进行模糊和扩散。假设画面中有两个区域普通墙壁float3(0.8, 0.8, 0.8) 霓虹灯float3(8.0, 1.0, 0.2)Bloom会主要处理霓虹灯而普通墙壁不会产生明显光晕。如果没有HDR而霓虹灯在进入后处理前就被截断为float3(1.0, 1.0, 0.2)那么它和普通白色物体的亮度差距会大幅减小Bloom效果就很难准确判断“谁是真正的高亮光源”。因此很多发光效果会故意写成float3 emission emissionColor * emissionIntensity;并让emissionIntensity 1.0;例如float3 emission float3(0.0, 3.0, 10.0);它在屏幕上经过色调映射后不一定是“十倍亮”但它会被Bloom识别为强光源从而形成漂亮的蓝色光晕。七、光照相加会不会违反能量守恒从物理渲染角度说确实需要注意这个问题。虽然颜色可以大于1但并不意味着我们可以无限制地把所有光照项随意相加。例如下面这种写法float3 finalColor albedo mainLight ambient specular emission;通常就不合理因为albedo是材质本色不是光照结果不能直接作为一份额外亮度加进去。更合理的结构应该是float3 diffuse albedo * lightColor * NdotL; float3 specular specularTerm * lightColor; float3 indirectDiffuse albedo * ambientGI; float3 finalColor diffuse specular indirectDiffuse emission;即使这样最终颜色仍然可能大于1但每一项都应当有明确来源。在基于物理的渲染模型PBR中还会更加注意能量守恒。比如金属度较高的材质其漫反射应该降低diffuse * (1.0 - metallic);因为金属表面的光能量更多体现在镜面反射中而不是漫反射中。再比如当高光很强时漫反射也不能毫无节制地保持原值否则材质看起来会“凭空制造能量”。因此正确的理解不是“颜色大于1一定不对。”而是“颜色大于1是允许的但每一份超过1的亮度都应该有合理的光照来源。”太阳照射下的白色物体、强烈的镜面反射、灯泡核心、自发光特效这些超过1都很合理但如果是因为同一盏光被重复累加十次那就是计算逻辑错误。八、自发光尤其容易超过1而且这通常是故意的自发光通常写成float3 emission emissionColor * emissionIntensity;例如float3 emissionColor float3(0.0, 0.8, 1.0); float emissionIntensity 5.0; float3 emission emissionColor * emissionIntensity;得到float3(0.0, 4.0, 5.0);这明显超过了1。但这正是发光材质需要的结果。如果发光颜色始终限制在0.0 ~ 1.0那么它看起来往往只是“颜色很鲜艳”而不是真正“很亮”。例如一个蓝色霓虹灯float3(0.0, 0.5, 1.0)更像普通蓝色塑料。而float3(0.0, 5.0, 12.0)在HDR和Bloom配合下才会更像真正发光的霓虹灯管。因此游戏中的火焰、魔法、激光、能量护盾等效果经常会把Emission Intensity设置到2、5、10甚至更高。九、Shader中要不要手动使用saturate(FinalColor)通常来说在HDR渲染流程中不建议对最终光照结果过早使用saturate()。例如不要轻易这样写float3 finalColor mainDiffuse mainSpecular additionalLightColor ambientGI emission; finalColor saturate(finalColor);因为这样会把所有超过1的HDR信息提前截断。原本float3(10.0, 2.0, 0.5)会变成float3(1.0, 1.0, 0.5)于是高亮层次丢失Bloom效果变弱色调映射无法正确工作强光区域容易变成一片死白自发光材质失去“真正发亮”的感觉。但saturate()不是完全不能用它非常适合用于一些本来就应该限制在0到1之间的中间量例如float NdotL saturate(dot(normalWS, lightDir));因为法线与光照方向的点积用于漫反射时本来就只需要0到1小于0背光不接受直接漫反射 大于1理论上不会出现 0到1正常受光程度。同样阴影衰减、遮罩值、透明度、插值因子等也经常需要使用saturate()。关键区别在于该限制的中间参数可以限制 最终HDR光照结果不要过早限制。十、一个更合理的最终输出示例下面是一个简化后的URP片元Shader光照合并示例float3 finalColor mainDiffuse mainSpecular additionalLightColor ambientGI customLight emission; // 不要在这里随便写 saturate(finalColor) return half4(finalColor, alpha);这里的finalColor完全可能是float3(4.2, 2.8, 1.1)只要项目启用了HDRUnity会先将它写入HDR颜色缓冲区。后续渲染流程可能是Shader输出HDR颜色 ↓ HDR Render Texture保存高亮信息 ↓ Bloom提取超过阈值的高亮区域 ↓ 曝光 Exposure 调整整体亮度 ↓ Tone Mapping压缩亮度范围 ↓ Gamma校正 / 色彩空间转换 ↓ 输出到显示器最终显示器虽然无法真正显示4.2或10.0这种原始浮点亮度但通过色调映射、曝光和Bloom观众仍然能感受到它比普通白色更亮、更耀眼、更具有光源感。十一、结论超过1不是问题丢失高亮信息才是问题回到最初的公式FinalColor 主光漫反射 主光高光 额外灯光 环境光 自定义光照 自发光;它的数值当然可能超过1而且在真实项目中经常超过1。例如多盏灯同时照射白色物体金属表面出现强烈镜面高光太阳反射在水面上火焰和灯泡具有高强度自发光技能特效产生强烈的魔法能量使用Bloom和HDR表现霓虹灯、激光、爆炸。这些情况中FinalColor大于1不仅正常还是实现高质量画面的基础。可以用一句话概括Shader中的1不是“颜色的绝对上限”而更像“标准显示白”。超过1代表比标准白更亮的HDR能量。如果使用LDR超过1的部分通常会被截断容易形成死白如果使用HDR超过1的亮度会被保留再通过Bloom、曝光和Tone Mapping压缩到屏幕可显示的范围中。因此在写光照Shader时我们真正需要关心的不是“FinalColor能不能超过1”而是光照项是否有合理来源是否重复计算、重复叠加是否在不该截断的地方使用了saturate项目是否开启HDR是否配置了合适的曝光、Bloom和Tone Mapping是否遵循了合理的能量守恒原则。只要这些环节正确FinalColor超过1不是危险信号而是画面拥有真实亮度层次、耀眼高光和发光氛围的重要前提。