参考
做风格化的美术参考就是 动漫,(刷抖音截的图。。
这个线条 本体色是黄色 再加上淡淡的黄泛光bloom,这个是我们要还原的东西

问题
在做风格化(Stylized)或二次元游戏时,很多美术同学和 TA 都会遇到一个令人抓狂的点:只要发光体稍微亮一点,颜色就全白了!
本来我们在材质里调了一个非常纯正、耀眼的高饱和度黄色。

开启 ACES 成了发白方块

如果直接使用 ACES Tonemapping,总是会把画面压得很暗,且颜色偏移了,这让人很头痛,所以我们要想解决办法,既能够让颜色压低到0到1之间,也能保留它原本的颜色特性。
(tonemapping就是将大于1的值,重新映射到0-1之间,因为大部分显示器(ldr 低动态范围)只能显示0-1之间)
aces+bloom的效果,本地变成了白色,黄色跑到泛光bloom上

解决方案:
罪魁祸首——传统的 ACES 算法是“一刀切”
它把颜色的 R、G、B 三个通道当成三个独立的员工来考核。当你把发光体(Emission)的强度飙到 3.5 这种“亮瞎眼”的数值时,原本数值最高的通道(比如黄色里的 Red 和 Green)会先顶到ACES 曲线的天花板,然后被无情地压平。
但是!数值比较低的 Blue 通道还在吭哧吭哧往上涨。 so:R、G、B 之间的比例全乱套了。
结果就是:只要亮度足够高,万物皆纯白。
这对于写实游戏来说叫“符合物理规律”,但对于风格化游戏来说,美术同学可能已经拔刀了:
“我画的那么纯正的赛博朋克霓虹灯,怎么全变白炽灯了?!”
作为专业的TalkArtist 肯定是有解决方案的!
我们需要转换思路,把“亮度”和“颜色”拆开来算。这种Trick在图形学里叫 Luminance-based Tonemapping(基于亮度的色调映射)。
使用保留色相的tonemapping,且画面不会像aces 一样降低画面亮度

具体操作分三步
1请客 2收下当狗 3斩首
第一步:把颜色里的“亮度”单独揪出来
不管你输入的是多亮瞎眼的 HDR 颜色 ,我们先用经典的 sRGB 感知公式,算算它在人眼看来到底有多亮,得出标量:

第二步:只揍“亮度”,不碰“颜色”
我们不把整个 RGB 扔进 ACES 里受折磨了,而是把刚才算出来的单通道亮度值 Lin 丢进色调映射曲线里(比如极度平滑的 GT 曲线或者中性曲线),得到一个被安全压缩过的目标亮度 Lout:

(我们在这一步搞定了防爆光,但根本没碰色相!)
第三步:见证奇迹的时刻(等比例缩放)
我们算一下亮度被压缩了多少(即 Lout / L_in),然后把这个比例,直接乘回到原始的 HDR 颜色上:

因为原始的 R、G、B 三个通道乘上了同一个数字,所以它们之间的比例关系被 100% 焊死了!色相(Hue)和饱和度(Saturation)毫发无损地保留了
核心控制参数
这个算法暴露了 6 个非常直观的参数
P (Peak): 目标显示器的最大亮度(SDR 环境下通常为 1.0)。
a (Slope/Contrast): 线性段的斜率/对比度。
m (Mid): 线性段的起始位置(分界点)。
l (Length): 线性段的长度。
c (Crush): 暗部曲率,决定了阴影被压暗的强度。
b (Black): 暗部的基底偏移量(绝对黑场)。
对于风格化游戏,最神奇的魔法在于将 a 设为 1.0,并拉长l 的值。 这样在 m 到 m+l 的广阔区间内,曲线的方程就是 y = x。这意味着场景中绝大多数的 Albedo 颜色会 100% 无损通过,只有极暗的阴影和高光过曝处才会被平滑压缩
狗头保命🐶:给美术留一颗“后悔药”
当然,有时候把颜色保得太死,发光体看起来会像一块发光的纯色塑料,少了一点点真实光学里“亮到发白”的错觉。作为一个成熟的 TA 写的工具,我们必须在面板上留一个 Color Preservation(色彩保留度)的滑动条。
我们在 Shader 里偷偷把“直男 ACES 算出来的偏白颜色”和“我们保下来的纯正颜色”都算一遍,然后让美术自己去调和(Lerp):
// 传统的 RGB 独立计算,高光会偏白(写实感)
float3 colorAces = ACES_Tonemap(Cin);
// 我们算出来的绝对保色结果(二次元/风格化感)
float3 colorStylized = Cin * (Lout / Lin);
// 把控制权交到美术手里,0.0 到 1.0 随便滑
float3 finalColor = lerp(colorAces, colorStylized, _ColorPreservationWeight);代码自取
using System;
using UnityEngine;
using UnityEngine.Rendering.PostProcessing;
[Serializable]
[PostProcess(typeof(StylizedTonemappingRenderer),PostProcessEvent.AfterStack,"LittleMonkeyBrother/Stylized Tonemapping")]
public sealed class StylizedTonemapping : PostProcessEffectSettings
{
[Header("Curve Parameters")]
[DisplayName("P / 最大亮度")]
[Tooltip("输出亮度上限。数值越小,高光越早被压缩。")]
[Range(0.1f, 4.0f)]
public FloatParameter maxBrightness = new FloatParameter { value = 1.0f };
[DisplayName("A / 线性对比度")]
[Tooltip("中段线性区的对比度。保持 1.0 可尽量维持原始中间调斜率。")]
[Range(0.1f, 4.0f)]
public FloatParameter linearContrast = new FloatParameter { value = 1.0f };
[DisplayName("M / 线性段起点")]
[Tooltip("从暗部 Toe 过渡到线性段的位置。")]
[Range(0.01f, 0.95f)]
public FloatParameter linearStart = new FloatParameter { value = 0.22f };
[DisplayName("L / 线性段长度")]
[Tooltip("线性段保留的亮度范围。数值越大,原始亮度保留越多。")]
[Range(0.0f, 1.0f)]
public FloatParameter linearLength = new FloatParameter { value = 0.4f };
[DisplayName("C / 暗部压低程度")]
[Tooltip("控制暗部压缩力度。觉得暗部太死黑时,可适当降低。")]
[Range(0.1f, 3.0f)]
public FloatParameter shadowCompression = new FloatParameter { value = 1.33f };
[DisplayName("B / 纯黑偏移")]
[Tooltip("建议保持在 0 附近。只在黑位压得太死时小幅抬高,过大容易让整张图发灰。")]
[Range(0.0f, 0.1f)]
public FloatParameter blackOffset = new FloatParameter { value = 0.0f };
}
public sealed class StylizedTonemappingRenderer : PostProcessEffectRenderer<StylizedTonemapping>
{
static readonly int PId = Shader.PropertyToID("_P");
static readonly int AId = Shader.PropertyToID("_A");
static readonly int MId = Shader.PropertyToID("_M");
static readonly int LId = Shader.PropertyToID("_L");
static readonly int CId = Shader.PropertyToID("_C");
static readonly int BId = Shader.PropertyToID("_B");
public override void Render(PostProcessRenderContext context)
{
var sheet = context.propertySheets.Get(Shader.Find("Hidden/LittleMonkeyBrother/StylizedTonemapping"));
sheet.properties.SetFloat(PId, settings.maxBrightness.value);
sheet.properties.SetFloat(AId, settings.linearContrast.value);
sheet.properties.SetFloat(MId, settings.linearStart.value);
sheet.properties.SetFloat(LId, settings.linearLength.value);
sheet.properties.SetFloat(CId, settings.shadowCompression.value);
sheet.properties.SetFloat(BId, settings.blackOffset.value);
context.command.BlitFullscreenTriangle(context.source, context.destination, sheet, 0);
}
}
Shader "Hidden/LittleMonkeyBrother/StylizedTonemapping"
{
Properties
{
}
SubShader
{
Cull Off ZWrite Off ZTest Always
Pass
{
HLSLPROGRAM
#pragma vertex VertDefault
#pragma fragment Frag
#include "Packages/com.unity.postprocessing/PostProcessing/Shaders/StdLib.hlsl"
TEXTURE2D_SAMPLER2D(_MainTex, sampler_MainTex);
float _P;
float _A;
float _M;
float _L;
float _C;
float _B;
float3 StylizedTonemap(float3 x)
{
const float kEpsilon = 1e-4;
float P = max(_P, 1e-3);
float a = max(_A, kEpsilon);
float m = clamp(_M, kEpsilon, P - kEpsilon);
float l = min(saturate(_L), 0.9999);
float c = max(_C, kEpsilon);
float b = _B;
float l0 = ((P - m) * l) / a;
float S0 = m + l0;
float S1 = m + a * l0;
float C2 = (a * P) / max(P - S1, kEpsilon);
float CP = -C2 / P;
float3 safeX = max(x, 0.0);
float3 w0 = float3(
1.0 - smoothstep(0.0, m, safeX.r),
1.0 - smoothstep(0.0, m, safeX.g),
1.0 - smoothstep(0.0, m, safeX.b));
float3 w2 = float3(
step(m + l0, safeX.r),
step(m + l0, safeX.g),
step(m + l0, safeX.b));
float3 w1 = 1.0 - w0 - w2;
float3 T = m * pow(max(safeX / m, 0.0), c.xxx) + b;
float3 S = P - (P - S1) * exp(CP * (safeX - S0));
float3 L = m + a * (safeX - m);
return max(T * w0 + L * w1 + S * w2, 0.0);
}
float4 Frag(VaryingsDefault i) : SV_Target
{
float3 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.texcoord).rgb;
col = StylizedTonemap(col);
return float4(col, 1.0);
}
ENDHLSL
}
}
Fallback Off
}