01从原帧到现在的画面
这套渲染是从一份 Ananta 的 RDC 抓帧开始做的。我想把角色放进 Unity,对着原图把材质、光照和后处理逐项还原。模型和纹理来自捕获资源;这里展示的是我做的渲染实现、工程整理和画面对照,不是角色模型与贴图的原创设计。
现在主体配色、眉毛透视、刘海投影、微笑线和脸颊泛红都已经接回来了。细描边通过投影外扩、Stable 重建和 4× MSAA 变得连贯了不少。头饰、衣服、眼睛高光和鞋子仍有差距,我没有把当前结果当成完整的 1:1。
当前工程使用 Unity 2022.3.62f1、URP 14.0.12 和 Windows D3D11。输入端的管线 Render Scale 是 0.75,后处理的 internalScale 是 1;2560×1440 输出对应 1920×1080 的相机输入,避免再缩小一次。场景选择 Stable 时域模式,4× MSAA,描边以输入分辨率下的 1 像素为尺度。
这里的滑动对比使用同机位的归档画面,只改变显示范围,没有重新配准或调色。它是静态结果对照,页面本身不实时运行 Unity Shader。
公式里的数字从哪里来
我把这些数字的来源分开说明,避免把抓帧里读到的值当成通用定律。
| 类型 | 怎样确认 | 阅读时要注意什么 |
|---|---|---|
| 数学与编码 | 能写回分数、基函数、单位换算或归一化关系 | 例如 255 是 8 位编码的最大值,0.039789 约为 1/(8π) |
| 公开算法的近似 | 能与公开公式或源码对应 | 近似本身也有适用条件,不等于精确物理计算 |
| 抓帧中的配置 | 能定位到原始常量缓冲或 Shader 指令 | 能确认当时用的是这个值,不代表别的角色和场景也该用它 |
| 实现选择与数值保护 | 能从当前代码确认作用 | Stable、像素尺度描边是我加入的选项;防止除零的下限也不能当成材质属性 |
下面每一处会说明数字的作用。原作者为什么恰好选了某个经验值,如果没有设计资料,我就写“选值依据未确认”。从指令还原出计算过程,和还原出当初的设计理由,是两件事。
02渲染流程怎么拆
我把流程分成三层:先解码角色数据,再按身体、脸、眼睛、头发分别计算材质,最后处理皮肤散射和屏幕空间合成。角色 Shader 决定表面颜色,后处理决定这些颜色最后怎样显示,两边都要对。
| 阶段 | 输入与处理 | 对应实现 |
|---|---|---|
| 资源解码 | 原生纹理、mip 链、材质编号、额外 UV、平滑法线、捕获常量 | ReadSurface / AnantaCaptureBindings |
| 表面与分类 | 身体、脸、眼睛的颜色与分类;头发保留背后脸部分类 | AnantaCharacterSurface / AnantaClassification |
| 角色照明 | Toon 漫反射、身体 BRDF、环境光、四组局部光、眼睛和头发专用分支 | ShadeDiffuse / AnantaCharacterLighting |
| 刘海绘制 | 半透明 → 不透明补绘 → 头发描边 | AnantaOutlineFeature / Hair Passes |
| 皮肤与投影 | 提取漫反射、SSS 滤波、逐样本替换,再乘后置阴影 | AnantaMsaaSurface / FragScatterBlur / ShadowMultiply |
| 曝光与时域 | 局部曝光、最近深度运动向量、历史拒绝、重建与反馈 | AnantaLocalExposure / AnantaTemporal / FragTemporal |
| 最终合成 | RCAS、运动模糊、六级 Bloom、32³ LUT、暗角 | AnantaPostProcessFeature / AnantaPost |
当前普通描边在主体之前调度,头发描边则必须放在脸和刘海之后。后处理也要等 URP 生成本帧运动向量后再运行。只把公式抄对,绘制顺序和缓冲绑定不对,结果还是会错。
03材质表、换色与法线解码
16×16 纹理是参数表
这张小纹理起初被当成普通 Ramp 使用,后来发现它存的是按材质编号排列的参数。Mask 的 alpha 编码了编号,我先还原行号,再按整数坐标读表。
row = 15 − min(15, trunc((255 × mask.a + 8.5) / 17))
parameter(column, row) = PaletteMap.Load(int2(column, row))
这里的 alpha 在归一化纹理范围内,trunc 对非负值就是去掉小数部分。Load 读取一个确定的 texel;如果用普通双线性采样,两行的材质参数会被混起来。身体皮肤使用 row=15,其他行则分别提供颜色、粗糙度偏移、金属度偏移和分段明暗阈值。
这里的 255、17、8.5、15 都有编码含义:先把 alpha 还原到 0–255,再按 17 一个档位解码,17=255/15,8.5=17/2 是半档偏移,最后用 15−编号 翻转到表格行。不是拿四个独立参数调颜色。当前实现保留捕获的截断方式,不能只把 17 改掉而不重做贴图编码。

换色发生在 Gamma 2.2 域
底色不是直接乘一个 Tint。捕获分支先把线性颜色转换到 Gamma 2.2 域,做两次 Overlay,再回到线性域。这里的 Gamma22 是幂函数,和后面最终输出使用的分段 sRGB 转换是两件事。
2.2 是这条换色路径采用的幂指数,0.5 是 Overlay 两个分支的中点,2 保证两段在中点接上。它们控制运算域和混色函数;2.2 并不是对完整 sRGB 曲线的精确表达。
G(C) = max(C, 0)^(1 / 2.2)
L(C) = max(C, 0)^2.2
Overlay(a, b) = 2ab 当 a ≤ 0.5
1 − 2(1 − a)(1 − b) 当 a > 0.5
C₁ = L(Overlay(Overlay(G(Cbase), A), B))
C₂ = L(Overlay(Overlay(G(Cbase), C), B))
Calbedo = lerp(Cbase, lerp(C₂, C₁, w), amount × PaletteStrength)
A、B、C 是不同换色层。身体主要从表的第 3、4、5 列读,混合权重 w 来自 mask.r;脸部使用捕获的肤色参数,w 改读 normalData.g,并由底图 alpha 控制 amount。头发只用一条两次 Overlay 的分支。上式省略最后的 BaseColor 乘法,保留了换色本身的计算。
Overlay 的条件按每个颜色通道分别判断,而且输入有上限 1 的处理。先后顺序也不能交换:阴影换色时,代码把 Ramp 放在 Overlay 的第一个输入,因此判断分支的颜色也随之改变。
AG 法线和 TBN
身体和头发的法线 XY 存在 A、G 通道。读完映射到 −1…1,乘强度,再补 Z。B、R 等通道在这里还有其他材质用途,不能按普通 RGB 法线贴图解释。
nxy = (raw.ag × 2 − 1) × NormalScale × captureScale
nz = sqrt(1 − min(dot(nxy, nxy), 1))
B = normalize(cross(N, T)) × tangent.w
Nworld = normalize(T × nx + B × ny + N × nz)
身体还结合正反面符号同时翻转 N、T、B。脸和眼睛这一捕获分支使用几何法线,不走上述 AG 解码;它们的相关纹理通道被拿来存颜色、遮罩或视差数据。
身体与头发从参数表第 12 列读取偏移:
offset = Table(12, row).gb × 2 − 1
metallic = saturate(offset.x + bodyNormalData.r)
roughness = max(0.02, saturate(offset.y + bodyNormalData.b))
头发不叠加这里的 bodyNormalData.r/b,皮肤行可以由捕获常量覆盖偏移。最后再加入美术用的粗糙度和金属度偏移,重新限制到有效范围。原生贴图还保留了 mip 链和采样设置,重复施加 mip bias、错误的各向异性等级都曾直接影响鞋子的细节。
×2−1 把 0–1 的表格值解码成 −1–1 的有符号偏移。粗糙度下限 0.02 则限制最尖锐的高光并避免后续 log₂(r) 遇到零;为什么原分支选 0.02 而不是另一小值,目前没有设计依据,换材质时需要重新验证。
查看当前实现 · MaterialRow
Shaders/Includes/AnantaCharacterSurface.hlsl · 第 42 行
int MaterialRow(float alpha)
{
// 67942:10-14 / 68247:30-34. Alpha packs an integer material ID.
return 15 - min(15, (int)((alpha*255.0+8.5)/17.0));
}查看当前实现 · Recolor
Shaders/Includes/AnantaCharacterSurface.hlsl · 第 58 行
float3 Recolor(float4 baseSample, float4 mask, float4 normalData, int row)
{
float3 base = baseSample.rgb;
if (_MaterialType > 1.5 && _MaterialType < 2.5 || _MaterialType > 3.5) return base;
float3 a=.5, b=.5, c=.5;
float mixWeight = mask.r, amount = 1;
if (_MaterialType < .5)
{
if (row == 15)
{
a = Gamma22(SkinTint(47)); b = Gamma22(SkinTint(63)); c = Gamma22(SkinTint(79));
}
else { a = Table(3,row).rgb; b = Table(4,row).rgb; c = Table(5,row).rgb; }
if (_UseCaptureConstants > .5 && asuint(_CaptureMaterial[31].x) != 0)
amount = saturate(2-2*baseSample.a);
}
else if (_MaterialType < 1.5)
{
a = Gamma22(SkinTint(30)); b = Gamma22(SkinTint(31)); c = Gamma22(SkinTint(33));
mixWeight = normalData.g; amount = baseSample.a;
}
else
{
// Hair has one two-stage overlay, not the body two-branch recolor.
return lerp(base, Linear22(Overlay(Overlay(Gamma22(base), Table(3,row).rgb), Table(4,row).rgb)), _PaletteStrength);
}
float3 g = Gamma22(base);
float3 first = Linear22(Overlay(Overlay(g,a),b));
float3 second = Linear22(Overlay(Overlay(g,c),b));
return lerp(base, lerp(second,first,mixWeight), amount*_PaletteStrength);
}查看当前实现 · DecodeNormal
Shaders/Includes/AnantaCharacterSurface.hlsl · 第 135 行
float3 DecodeNormal(Varyings i, float4 raw, int row, float faceSign)
{
float3 n = normalize(i.normalWS);
// Face and eye surface shader uses the geometric normal; t12 is color data.
if (_MaterialType > .5 && _MaterialType < 2.5 || _MaterialType > 3.5) return n;
float scale = _NormalScale;
if (_UseCaptureConstants > .5)
scale *= _MaterialType < .5 ? (row == 15 ? CAPTURE_SKIN_NORMAL_SCALE : CAPTURE_BODY_NORMAL_SCALE) : CAPTURE_HAIR_NORMAL_SCALE;
float2 xy = (raw.ag*2-1)*scale;
float3 tangent = normalize(i.tangentWS.xyz);
float3 bitangent = normalize(cross(n,tangent))*i.tangentWS.w;
// DXBC uses cross(N,T)*sign(T.w), including SV_IsFrontFace for body.
if (_MaterialType < .5) { n *= faceSign; tangent *= faceSign; bitangent *= faceSign; }
return normalize(tangent*xy.x + bitangent*xy.y + n*sqrt(1-min(dot(xy,xy),1)));
}04Toon 漫反射与分段 Ramp
身体和头发的主光明暗使用一个带遮罩偏移的 N·L。不是普通 Lambert,也不是把半 Lambert 输入随意换成一张 Ramp。
S(x) = t²(3 − 2t),其中 t = saturate(x)
u = dot(N, L) + clamp(4 × mask.g − 2, −1, 1)
shade = S(u / max(width, 10⁻⁵))
shade = min(shade, shadowAttenuation × saturate(Luma(lightColor)))
width 控制明暗过渡宽度,由材质表和全局 Toon 参数混合得到。脸部会把初始 shade 换成 FaceShadow,眼睛则换成 EyeShadow,然后再受主光遮挡和颜色亮度限制。
Ramp 本身有低、中、高三种颜色,四个阈值决定两段平滑过渡和中间的平台。下面 a、b、c、d 是实际读取的阈值,不是统一给所有部位的一组常数。
R(v) = Clow v < a
lerp(Clow, Cmid, S((v − a)/(b − a))) a ≤ v < b
Cmid b ≤ v < c
lerp(Cmid, Chigh, S((v − c)/(d − c))) v ≥ c
代码对分母有最小值保护。普通身体 Ramp 的坐标还受 u、主光遮挡与光色亮度共同限制;脸部用 shade,并在 RampColor 里把坐标乘 2。阴影颜色先做 Overlay,再按 shade 回到原底色。
主光漫反射、间接光和附加光分开累加:
Cdirect = Calbedo × (1 − metallic) × shade × lightColor
× directScale / π × diffuseEnergy
Cdiffuse = Cindirect + Cdirect + Cadditional
Cindirect 使用换色后的阴影底色和捕获的球谐环境光,身体还结合 AO;diffuseEnergy 则由 BRDF LUT 的积分结果算出。脸颊染色发生在三项累加之后,后面会单独说明。
查看当前实现 · ShadeDiffuse
Shaders/Includes/AnantaCharacterPasses.hlsl · 第 2 行
float3 ShadeDiffuse(Varyings i, Surface s, Light light, out float shade)
{
float3 l=normalize(light.direction);
float width=.2;
if (_UseCaptureConstants>.5)
{
width = _MaterialType<.5 && s.row==15 ? _CaptureMaterial[183].z : Table(12,s.row).r;
if (_MaterialType>.5 && _MaterialType<1.5) width=_CaptureMaterial[21].y;
float target = s.row==15 ? _CaptureToon[24].x : _CaptureToon[24].y;
width=lerp(width,target,_CaptureToon[24].z);
}
float signedLight=dot(s.normal,l)+clamp(s.mask.g*4-2,-1,1);
shade=Smooth01(signedLight/max(width,1e-5));
if (_MaterialType>.5 && _MaterialType<1.5) shade=FaceShadow(i.uv,l);
if (_MaterialType>1.5 && _MaterialType<2.5 || _MaterialType>3.5)
shade=_UseCaptureConstants>.5 ? EyeShadow(i.faceUv,l,lerp(_CaptureMaterial[25].w,_CaptureToon[24].x,_CaptureToon[24].z),true) : saturate(dot(s.normal,l));
shade=min(shade,light.shadowAttenuation*saturate(Luma(light.color)));
float3 shadowed=s.albedo;
if (_UseCaptureConstants>.5 && (_MaterialType<1.5 || _MaterialType>2.5 && _MaterialType<3.5))
{
float coordinate=min(signedLight+1,light.shadowAttenuation*saturate(Luma(light.color))+.75);
if (_MaterialType>.5 && _MaterialType<1.5) coordinate=shade;
float3 ramp=RampColor(coordinate,s.row);
float amount=_MaterialType<.5 && s.row==15 ? _CaptureMaterial[107].z : Table(10,s.row).b;
if (_MaterialType>.5 && _MaterialType<1.5) amount=_CaptureMaterial[22].w;
amount*=_CaptureToon[12].x;
// Shadow composition switches overlay's selector to the ramp color.
shadowed=lerp(s.albedo,Linear22(Overlay(Gamma22(ramp),Gamma22(s.albedo))),amount);
shadowed=lerp(shadowed,s.albedo,shade);
}
float3 ambientNormal=s.normal;
if (_MaterialType>.5 && _MaterialType<1.5 && _UseCaptureConstants>.5 && asuint(_CaptureMaterial[5].z)==0)
ambientNormal=_CaptureFaceAxes[1].xyz;
float3 indirect=shadowed*(1-s.metallic)*CapturedAmbient(ambientNormal,s.row);
if (_MaterialType<.5)
{
float ao=lerp(s.mask.b,1,shade);
if (s.row==15 && _UseCaptureConstants>.5) indirect*=lerp(_CaptureMaterial[0].rgb,1,ao);
else indirect*=ao;
}
float directScale=_UseCaptureConstants>.5 ? _CaptureToon[14].y : .5;
float3 additional=_UseCaptureConstants>.5 ? AdditionalDiffuse(i,s,SafeNormalize(GetWorldSpaceViewDir(i.positionWS))) : 0;
float3 view=SafeNormalize(GetWorldSpaceViewDir(i.positionWS));
float nv=dot(_MaterialType>.5 && _MaterialType<1.5?_CaptureFaceAxes[1].xyz:s.normal,view);
float3 direct=s.albedo*(1-s.metallic)*shade*light.color*(directScale/PI)*DirectDiffuseEnergy(s,nv);
if (_DebugView>4.5 && _DebugView<5.5) return indirect;
if (_DebugView>5.5 && _DebugView<6.5) return additional;
if (_DebugView>7.5) return additional;
if (_DebugView>6.5) return direct;
float3 diffuse = indirect + direct + additional;
if (_MaterialType > .5 && _MaterialType < 1.5 && _UseCaptureConstants > .5)
diffuse *= FaceLightingTint(i.uv, view, shade);
return diffuse;
}05身体高光、环境反射与局部光
身体高光的具体 BRDF
身体保留了 GGX 分布、近似可见性和捕获分支的 Fresnel。我用下面这些符号把 BodySpecular 展开:r 是粗糙度,m 是金属度,H=normalize(V+L),nₗ=saturate(N·L),nᵥ=min(|N·V|+10⁻⁵,1)。
α = r²,α² = r⁴
D = α² / [π × ((N·H)² × (α² − 1) + 1)²]
Vis = 0.5 / [nₗ(nᵥ(1 − α) + α) + nᵥ(nₗ(1 − α) + α)]
F₀ = lerp((0.04, 0.04, 0.04), Calbedo, m)
F₉₀ = saturate(50 × F₀.g)
F = lerp(F₀, F₉₀, (1 − saturate(V·H))⁵)
E = 1 + F₀ × (1 − I.r) / max(I.r, 10⁻⁵)
BRDFbody = D × Vis × F × E × nₗ
D 和 Vis 在源码里分别有 10⁻⁸ 的分母保护。I 是 BRDF LUT 在 (saturate(nᵥ), 1−r) 的采样结果,其中 nᵥ 是上面 BodySpecular 的绝对值与偏移处理结果。F₉₀ 取 F₀ 的绿色通道,是这一捕获分支的写法,不能直接替换成固定白色。E 是能量补偿,关闭捕获 BRDF 时返回 1。
0.04 是非金属分支的法向反射率假设:空气到折射率约 1.5 的介质,F₀=((1.5−1)/(1.5+1))²=0.04。它不适用于所有介质。五次幂来自 Schlick 型 Fresnel 近似;这里的 Vis 也是近似可见性项。50 是捕获采用的 F₉₀ 控制,令绿色 F₀ 达到 1/50=0.02 时饱和到 1,属于低反射率的经验处理,不能由 Fresnel 定律推出这个 50。基础 Fresnel 关系可对照 pbrt 的 Disney 材质实现。
主光漫反射的能量系数使用同一张 LUT 的 RG 通道:
Fintegrated = [F₀ × I.r + (F₉₀ − F₀) × I.g] × E
diffuseEnergy = 1 − Luma(Fintegrated)
Luma(C) = 0.212673Cr + 0.715152Cg + 0.072175Cb
实际身体高光合成还保留了一处捕获运算顺序:
Bmain = BodySpecular(mainLight) × Table(0, row).rgb × mask.b
Cspec = Bmain × (mainLightColor + AdditionalSpecular) + EnvironmentSpecular
这里的 AdditionalSpecular 已经包含局部光的 BRDF 和衰减。当前实现按源分支继续与 Bmain 相乘,没有直接把它当成一项普通附加高光加到画面上。把这一步改成常见的直接相加,曾让衣服显得发白。
环境反射不是直接加 Cubemap RGB
环境采样方向从法线向反射向量混合,粗糙度决定混合强度和 mip。粗糙表面会更多靠近法线方向,再结合法线方向可见性和遮挡。
q = 1 − r²
β = (r² + sqrt(saturate(q))) × q
R = 2(N·V)N − V
d = lerp(N, R, β)
visibility = saturate(dot(d, N) × environmentVisibilityScale)
d = normalize(d + N × max(−dot(d, N), 0))
LOD = 6 + 1.2 × log₂(max(r, 0.02))
occlusion = saturate(mask.b + max(mask.b + N·V, 10⁻⁶)^q − 1)
采样得到的 radiance 先转亮度,再由 CapturedAmbient 的环境色着色:
Cenv = [(F₀ × I.r + (environmentF90 − F₀) × I.g) × E]
× environmentColor × Luma(radiance) × occlusion × visibility
因此这里不是直接把彩色 Cubemap 反射加上去。身体的皮肤行不使用这条环境高光分支,眼睛也有自己的 EyeEnvironment。
球谐环境光如何收敛到 Toon 颜色
CapturedAmbient 使用捕获的七组向量构造二阶球谐。设法线为 (x,y,z),基础项是:
b₀ = (0.886228, −1.023328y, 1.023328z, −1.023328x)
b₁ = (1.092548xy × 0.785398, −1.092548yz × 0.785398,
(2z²−x²−y²) × 0.247708, −1.092548xz × 0.785398)
b₂ = (x²−y²) × 0.429043
这组常量已经包含该分支的缩放,不能再套一次常见 SH 积分系数。源码先根据捕获 SH 能量计算 k,令 first=(k+1)/2、second=k×first,分别缩放一阶项与二阶项。一些面部或皮肤分支在指定标志下令 k=−1,从而只留下常数环境项。
球谐这组数字可以拆回基函数与余弦卷积:0.886228≈0.282095π,1.023328≈0.488603×2π/3,0.785398≈π/4,0.247708≈0.315392π/4,0.429043≈0.546274π/4。1.092548 是二阶交叉项的基函数系数。正负号还取决于当前坐标轴约定。可推导的是这些基函数系数;后面根据能量压低一、二阶项的规则仍是捕获中的风格化处理。
各通道与对应捕获向量点乘,加入最后一组二阶项后,得到非负的 sh 并乘 4。然后分开控制环境亮度和饱和度:
Y = max(Luma(sh), 10⁻⁶)
intensity = clamp(Y × intensityScale, intensityMin, intensityMax)
saturation = min(18Y, 1) × saturationControl
Cambient = intensity × lerp(1, sh / Y, saturation)
这样保留环境色的同时,可以限制它对角色明暗的影响。当前 SH 能量和 k 的计算仍由 CapturedAmbient 按捕获参数执行,不能把上面的最后三行单独当成完整球谐求值。
四组局部光与边缘光
局部光的输入还要解包半精度浮点和整数模式。位置使用捕获世界坐标,我把预览坐标加上固定原点偏移 (900,−200,1000) 再求光照方向;它是这个场景的坐标还原,不是一般性的世界坐标约定。
Δ = lightPosition − (positionWS + capturedOrigin)
d² = max(dot(Δ, Δ), 10⁻⁸),L = Δ / sqrt(d²)
fade = max(1 − (radiusInvSq × d²)², 0)
spot = saturate(dot(spotDirection, L) × spotScale + spotOffset)
attenuation = fade² × spot² × |intensity|
/ max(d² × inverseScale + inverseBias, 10⁻⁸)
diffuse 分支会根据捕获模式决定 inverseScale 和 inverseBias;高光分支使用当前捕获光的 Toon 距离衰减覆盖。每盏光还分硬/软 Toon 边界、边缘光、指定距离响应和粗糙漫反射等模式,并不是一个统一点光公式。
边缘光模式的表面响应为:
response = saturate(N·L) × [1 − S((sqrt(saturate(N·V)) − edge × k) / max(width × k, 10⁻⁵))]
头发的 k=2×mask.r,其他普通表面为 1;当前面部/眼部的该模式没有贡献。早期漏掉这项选择,第四盏光就在本应为零的位置额外加了光,把腿部明暗抹平了。
粗糙漫反射模式还带视线和灯光的相关项:
c = V·L − (N·V)(N·L)
c′ = c + saturate(c) × [1 / max(max(N·V, N·L), 10⁻⁵) − 1]
response = [1 + rough × saturate(2V·L+1) × c′] × saturate(N·L) / π
普通身体 rough=r²,面部/眼部可用 1 或切到专用 SDF 模型。附加光漫反射最后乘底色、非金属比例、对应 alpha 和全局局部光强度。
查看当前实现 · BodySpecular
Shaders/Includes/AnantaCharacterLighting.hlsl · 第 17 行
float3 BodySpecular(Surface s, float3 view, float3 l)
{
float nl=saturate(dot(s.normal,l));
float nv=min(abs(dot(s.normal,view))+.00001,1);
float3 h=SafeNormalize(view+l);
float nh=saturate(dot(s.normal,h)), vh=saturate(dot(view,h));
float alpha=s.roughness*s.roughness, a2=alpha*alpha;
float denominator=nh*nh*(a2-1)+1;
float distribution=a2/max(PI*denominator*denominator,1e-8);
float visibility=.5/max(nl*(nv*(1-alpha)+alpha)+nv*(nl*(1-alpha)+alpha),1e-8);
float3 f0=lerp(.04.xxx,s.albedo,s.metallic);
float fresnel=pow(1-vh,5);
float3 f=lerp(f0,saturate(f0.g*50).xxx,fresnel);
return distribution*visibility*f*SpecularEnergy(f0,nv,s.roughness)*nl;
}查看当前实现 · EnvironmentSpecular
Shaders/Includes/AnantaCharacterLighting.hlsl · 第 51 行
float3 EnvironmentSpecular(Surface s, float3 view, float3 environmentColor)
{
if(_UseCapturedEnvironment<.5 || _UseCapturedBRDF<.5 || _MaterialType<.5 && s.row==15) return 0;
float nv=dot(s.normal,view);
float oneMinusR2=1-s.roughness*s.roughness;
float blend=(s.roughness*s.roughness+sqrt(saturate(oneMinusR2)))*oneMinusR2;
float3 reflected=2*nv*s.normal-view;
float3 direction=lerp(s.normal,reflected,blend);
float visibility=saturate(dot(direction,s.normal)*_CaptureToon[23].w);
direction=normalize(direction+s.normal*max(-dot(direction,s.normal),0));
float occlusion=saturate(s.mask.b+pow(max(s.mask.b+nv,1e-6),oneMinusR2)-1);
float lod=6+1.2*log2(max(s.roughness,.02));
float3 radiance=max(SAMPLE_TEXTURECUBE_LOD(_CaptureEnvironmentMap,sampler_CaptureEnvironmentMap,direction,lod).rgb,0);
float3 f0=lerp(.04.xxx,s.albedo,s.metallic);
float2 integral=SAMPLE_TEXTURE2D_LOD(_CaptureBRDFMap,sampler_CaptureBRDFMap,float2(saturate(nv),1-s.roughness),0).rg;
float3 integrated=(f0*integral.x+(_CaptureToon[23].z-f0)*integral.y)*SpecularEnergy(f0,nv,s.roughness);
return integrated*environmentColor*Luma(radiance)*occlusion*visibility;
}06脸部 SDF 阴影和脸颊泛红
脸部阴影先处理光向,再比较 SDF
FaceShadow 不直接使用面部网格的 N·L。主光先把仰角限制到 ±45°,再投影到捕获的脸部前向 F 和侧向 R。附加光可以按自己的模式跳过仰角限制。
θ = clamp(atan2(Ly, max(length(Lxz), 10⁻⁶)), −π/4, π/4)
L′ = (Lx cosθ / h, sinθ, Lz cosθ / h),h=max(length(Lxz), 10⁻⁶)
f = dot(F, L′)
warp = sign(f) × max(|f|, 0.667 × min(3.333|f|, 1)^0.3)
d = FaceMap(uv).r 当 dot(R,L′) < 0
FaceMap(1−u,v).r 其他情况
faceShade = S((d − (center − 0.5warp) + 0.1width) / max(0.2width, 10⁻⁵))
center 和 width 分别来自面部参数,width 还与全局 Toon 宽度混合。最后只有 abs(FaceMap(uv).b)>0.071429 的分类区域保留 faceShade。这一步选的是左右光照对应的 SDF,不是把整张脸的阴影纹理平移。
脸部这几个系数分别在控制什么
| 数字 | 对应作用 | 已确认的来源与边界 |
|---|---|---|
| 3.333 | 把正向分量放大,约在绝对值 0.30003 时让内部 min 到达 1 | 原 Shader 67910 的指令字面量;不要为了整齐改成精确 10/3 |
| 0.3 | 0–1 区间取小于 1 的幂,抬高小正向分量的响应 | 原指令字面量;这个指数的原始选值依据未确认 |
| 0.667 | 控制幂函数支路饱和后的高度,再与原始绝对值取 max | 原指令字面量;它不是必须等于精确 2/3 的物理常数 |
| 0.5warp | 把光向响应变成 SDF 阈值的位移 | 捕获采用的阴影映射幅度 |
| 0.1width、0.2width | 阈值左右各留 0.1width,总过渡宽度为 0.2width | 两个系数互相配套,不是两项独立强度 |
| ±π/4 | 主光仰角限制到 ±45° | 风格化限制,不是正常光照必须采用的角度 |
取 |f|=0.1,warp 的绝对值约为 0.480;取 0.3,约为 0.667;取 0.5,仍约为 0.667;取 0.8,则回到 0.8。这条曲线先迅速增强、再出现一段近似平台,最后由原始分量接管,所以阴影移动不会与光线角度线性对应。我能从公式确认这个效果,但不能仅凭反汇编断言原作者为什么选了这条曲线。
分类比较中的 0.071429≈1/14,以及下面的 0.785714≈11/14、0.142857≈2/14,是 FaceMap.b 分类区间的边界、中心和容差。它们在原 Shader 的比较指令中可直接找到;这些分数只解释数值结构,不能据此给每个分类随意起解剖名称。分类逻辑需要和当前贴图一起使用。
泛红是照明后的第二套 SDF 权重
FaceLightingTint 的左右选择由视线 V 决定,而不是光线 L。先用视线在脸部侧向上的分量混合双侧 SDF,再根据明暗生成粉红染色权重。
s = dot(V, R),a = min(2|s|, 1)
selected = s < 0 ? dLeft : dRight
field = lerp(min(dLeft, dRight), selected, a)
center′ = tintCenter + 0.05a
width′ = (0.5 + 0.5a) × tintWidth
M = S((field − center′ + 0.1width′) / max(0.2width′, 10⁻⁶))
w = lerp(max(0.65 − M, 0), 1 − M, shade)
Ctint = lerp(1, pinkColor, w)
Cdiffuse ← Cdiffuse × Ctint
abs(FaceMap.b−0.785714)≤0.142857 的类别把 M 设成 1,排除染色。开关存的是整数位模式,我用 asuint(_CaptureMaterial[21].w) 读它;把很小的浮点编码当普通浮点阈值比较,会把原本开启的标志读成关闭。
泛红里的 0.05 限制视角造成的阈值偏移,0.5+0.5a 让宽度从一半变到完整宽度,0.65 限制暗部分支可达到的染色权重。它们都是捕获的风格控制,不是皮肤散射的物理常数;目前没有原始美术调参记录。
ShadeDiffuse 同时被主体和散射输出调用,因此这层泛红也会进入皮肤散射,而不是最后在屏幕上叠一层粉红贴图。

查看当前实现 · FaceShadow
Shaders/Includes/AnantaCharacterSurface.hlsl · 第 174 行
float FaceShadow(float2 uv, float3 lightDir, float widthOverride=0, bool limitElevation=true)
{
// 67910:33-60 clamps elevation to +/-45 degrees, before face-axis projection.
float horizontal = max(length(lightDir.xz),1e-6);
float angle = clamp(atan2(lightDir.y,horizontal),-.785398,.785398);
float3 limited = limitElevation ? float3(lightDir.x*cos(angle)/horizontal,sin(angle),lightDir.z*cos(angle)/horizontal) : lightDir;
float3 forward = _UseCaptureConstants>.5 ? CAPTURE_FACE_FORWARD : float3(0,0,1);
float3 side = _UseCaptureConstants>.5 ? CAPTURE_FACE_SIDE : float3(1,0,0);
float front = dot(forward, limited);
float warped = max(abs(front),pow(min(abs(front)*3.333,1),.3)*.667)*sign(front);
float4 sdf = SAMPLE_TEXTURE2D(_FaceMap,sampler_FaceMap,uv);
float4 mirrored = SAMPLE_TEXTURE2D(_FaceMap,sampler_FaceMap,float2(1-uv.x,uv.y));
float selected = dot(side,limited)<0 ? sdf.r : mirrored.r;
float center = _UseCaptureConstants>.5 ? _CaptureMaterial[21].z : .5;
float width = _UseCaptureConstants>.5 ? lerp(_CaptureMaterial[21].y,_CaptureToon[24].x,_CaptureToon[24].z) : .2;
if (widthOverride>0) width=widthOverride;
float shadow = Smooth01((selected-(center-.5*warped)+.1*width)/max(.2*width,1e-5));
// The captured face permutation has cb7[7].x=y=0: exclude ID zero.
return shadow * (abs(sdf.b)>.071429 ? 1 : 0);
}查看当前实现 · FaceLightingTint
Shaders/Includes/AnantaCharacterSurface.hlsl · 第 196 行
float3 FaceLightingTint(float2 uv, float3 view, float shade)
{
if (asuint(_CaptureMaterial[21].w) == 0) return 1;
float4 sdf = SAMPLE_TEXTURE2D(_FaceMap, sampler_FaceMap, uv);
float mirrored = SAMPLE_TEXTURE2D(_FaceMap, sampler_FaceMap, float2(1 - uv.x, uv.y)).r;
float side = dot(view, CAPTURE_FACE_SIDE);
float sideWeight = min(2 * abs(side), 1);
float selected = side < 0 ? sdf.r : mirrored;
float field = lerp(min(sdf.r, mirrored), selected, sideWeight);
float center = _CaptureMaterial[22].x + .05 * sideWeight;
float width = (.5 + .5 * sideWeight) * _CaptureMaterial[22].y;
float faceMask = Smooth01((field - center + .1 * width) / max(.2 * width, 1e-6));
// The source exempts the packed face category around 0.785714.
if (abs(sdf.b - .785714) <= .142857) faceMask = 1;
float shadowWeight = max(.65 - faceMask, 0);
float weight = lerp(shadowWeight, 1 - faceMask, shade);
return lerp(1, _CaptureMaterial[24].rgb, weight);
}07眼睛的分层视差与高光
虹膜和装饰层使用不同的视差比例。先把视线投到切线空间 vTS=(T·V,B·V),再围绕 UV 中心缩放。
uvIris = (uv − parallaxIris × vTS − 0.5) × irisScale + 0.5
uvLayer = (uv − parallaxLayer × vTS − 0.5) × layerScale + 0.5
radial = saturate(1 − length(uv−0.5) / max(radius, 10⁻⁵))
depth = depthScale × (1 − 1 / (1 + 0.5radial))
depth 用于镜像遮罩的视差偏移。虹膜颜色先按虹膜贴图 alpha 混合,再按底图 alpha 混合一次;角度 Tint 之后,还要按同一底图 alpha 第二次回混:
irisColor = lerp(irisBaseTint, iris.rgb, iris.a)
first = lerp(base.rgb, irisColor, base.a)
color = lerp(base.rgb, first × irisTint, base.a)
irisTint 由处理后的光向与法线求角度,再经两段 Smooth01 和四次幂修形。省掉第二次 alpha 混合,即使改动像素不多,虹膜颜色也会偏离。遮罩暗区扣除后,再加装饰层发光并按 layer.a 混合覆盖层。
眼睛高光也不是普通身体 BRDF。EyeHighlights 把处理后的光向投到切线平面,移动圆形高光中心,再同时生成宽高光和锐高光。
center = 0.5 − projectedLight × centerScale
radius = max(lerp(radiusNear, radiusFar, length(uvIris−0.5) × length(projectedLight)), 0.001)
profile = saturate(radius − length(uvIris−center)) / radius
profile4 = profile⁴
energy = max(highlightScale / radius, 1)²
宽高光使用 profile,窄高光使用 profile4,并分别通过阈值与 Smooth01 控制。合并后的 glint 还逐通道平方,再乘遮罩。除此之外,代码从 uv+offset、uv+1.85offset、uv+0.85offset 分别取 R、G、B,生成带色散偏移的虹膜光;offset=vTS×length(vTS)×scale。
这里的 0.5 把缩放中心放到 UV 中心,radius 的 0.001 下限防止后续除零。profile⁴ 会压窄高光,后面的平方又调整峰值。1.85、0.85 分别放大绿色、缩小蓝色的采样偏移,是捕获的色散式外观控制;没有使用波长与折射率关系,不能把它解释成精确的光学色散。
这些高光最终与环境光、主光和附加光共同作用,还有独立 EyeEnvironment 和反射输入。目前眼睛高光仍有可见差距,我保留了这一点,没有把“视差和混色已补齐”当成眼睛整体完成。
查看当前实现 · EyeAlbedo
Shaders/Includes/AnantaCharacterSurface.hlsl · 第 89 行
float3 EyeAlbedo(Varyings i, float4 baseSample)
{
// 67945:197-258. The iris and decorative layer have separate parallax.
float3 n=normalize(i.normalWS), t=normalize(i.tangentWS.xyz);
float3 b=normalize(cross(n,t))*sign(i.tangentWS.w);
float3 v=SafeNormalize(GetWorldSpaceViewDir(i.positionWS));
float2 viewTS=float2(dot(t,v),dot(b,v));
float radial=saturate(1-length(i.uv-.5)/max(_CaptureMaterial[27].y,1e-5));
float depth=_CaptureMaterial[27].z*(1-rcp(1+.5*radial));
float2 mirrorUv=float2(i.tangentWS.w>=0?1-i.uv.x:i.uv.x,i.uv.y);
float2 maskUv=mirrorUv+depth*float2(-sign(i.tangentWS.w)*viewTS.x,viewTS.y);
float shiftedMask=SAMPLE_TEXTURE2D(_NormalMap,sampler_NormalMap,maskUv).r;
float baseMask=SAMPLE_TEXTURE2D(_NormalMap,sampler_NormalMap,i.uv).r;
float2 irisUv=(i.uv-_CaptureMaterial[26].w*viewTS-.5)*_CaptureMaterial[29].x+.5;
float4 iris=SAMPLE_TEXTURE2D(_BaseMap,sampler_BaseMap,irisUv);
float3 irisColor=lerp(_CaptureMaterial[28].rgb,iris.rgb,iris.a);
// 67945:225-249: the first iris blend is tinted by an angle-dependent
// factor, then blended back into the base with its alpha a second time.
float3 lightDir=normalize(_CaptureLightDirection.xyz);
float horizontal=max(length(lightDir.xz),1e-6);
float elevation=clamp(atan2(lightDir.y,horizontal),-.785398,.785398);
lightDir=float3(lightDir.x*cos(elevation)/horizontal,sin(elevation),lightDir.z*cos(elevation)/horizontal);
float3 localLight=float3(dot(lightDir,_CaptureFaceAxes[0].xyz),abs(dot(lightDir,_CaptureFaceAxes[1].xyz)),dot(lightDir,_CaptureFaceAxes[2].xyz));
float angle=radians(_CaptureMaterial[5].x), cosine=cos(angle);
float3 boundary=SafeNormalize(SafeNormalize(float3(localLight.x,0,localLight.z))*tan(angle)+float3(0,1,0));
float3 bentLight=SafeNormalize(lerp(boundary,localLight,saturate((localLight.y-cosine)/max(cosine,1e-6))));
float3 projectedLight=float3(dot(bentLight,_CaptureFaceAxes[4].xyz),dot(bentLight,_CaptureFaceAxes[5].xyz),dot(bentLight,_CaptureFaceAxes[6].xyz));
float3 localNormal=float3(dot(n,_CaptureFaceAxes[0].xyz),dot(n,_CaptureFaceAxes[1].xyz),dot(n,_CaptureFaceAxes[2].xyz));
localNormal+=float3(_CaptureMaterial[25].y,0,-sign(i.tangentWS.w)*_CaptureMaterial[25].x);
localNormal=SafeNormalize(localNormal)*float3(-1,1,-1);
float3 projectedNormal=float3(dot(localNormal,_CaptureFaceAxes[4].xyz),dot(localNormal,_CaptureFaceAxes[5].xyz),dot(localNormal,_CaptureFaceAxes[6].xyz));
float inverseFacing=1-saturate(dot(projectedNormal,projectedLight));
float irisAngle=Smooth01((.7*_CaptureMaterial[37].x-(1-.5*inverseFacing))/(-.5-.5*inverseFacing));
float thresholdLow=.5-(.01+.49*_CaptureMaterial[37].z);
float thresholdHigh=.51+.49*_CaptureMaterial[37].z;
float tintWeight=Smooth01((irisAngle-thresholdLow)/max(thresholdHigh-thresholdLow,1e-6));
tintWeight+=(2*_CaptureMaterial[37].y-1)*(pow(tintWeight,4)-tintWeight);
float3 irisTint=lerp(_CaptureMaterial[36].rgb,1,tintWeight);
float3 firstBlend=lerp(baseSample.rgb,irisColor,baseSample.a);
float3 color=lerp(baseSample.rgb,firstBlend*irisTint,baseSample.a);
color*=1-max(shiftedMask,baseMask);
float2 layerUv=(i.uv-_CaptureMaterial[27].x*viewTS-.5)*_CaptureMaterial[29].z+.5;
float4 layer=SAMPLE_TEXTURE2D(_EyeLayerMap,sampler_EyeLayerMap,layerUv);
color+=layer.rgb*_CaptureMaterial[30].rgb*exp2(_CaptureMaterial[31].x)*(1-layer.a);
return lerp(color,layer.rgb*_CaptureMaterial[31].y,layer.a);
}08头发的视角高光、色带和刘海透视
当前捕获选择的是蓝色视角高光
头发不叠加身体那条白色高光。当前捕获开关选中蓝色视角分支;Tint 亮度大于 1.001 时,先按亮度归一化,然后计算:
illumination = Cambient × (2 × ambientScale)
+ mainLightColor × shade × directScale / π
CspecHair = tint × mask.b × S((max(saturate(N·V),0.001) − 0.5) × 1.996008)
× Luma(illumination)
它会随视角增强,但不是简单的 (1−N·V)^power 边缘高光。主漫反射还会乘捕获的头发颜色参数,随后一起进入色带染色。
1.996008≈1/0.501 是视角门控的斜率:N·V 不到 0.5 时权重为零,随后上升,归一化输入约在 1.001 处达到 1。这解释了它接近 2 的原因;原作者为什么把端点放在约 1.001,目前未确认。色带里的 0.6 把光向以 60% 权重偏向手工方向,0.1 限制视线造成的位置偏移,都是风格参数。
HairBandTint 使用手工光向、视线、切线副轴和噪声构造三组位置。第三组会排除前两组的重叠,再乘法染色完成的头发颜色。
H = normalize(normalize(lerp(L, authoredLight, 0.6)) + V)
bH = dot(B, H)
noise = 1 − 2 × HairNoise(noiseUV).g
pi = offseti − bH + noise × noiseScalei + 0.1saturate(authoredLight·V)
Band(x) = 1 − S((x−edgePositive)/widthPositive) x > 0
1 − S((−x−edgeNegative)/widthNegative) x ≤ 0
third = Band(p3) × (1 − saturate(Band(p1)+Band(p2)))
Chair ← Chair × lerp(1, bandTint, third × mask.b × facingFade × shade)
每条色带有独立的正负边缘和宽度,分母设最小值保护。facingFade 根据 N·V 的捕获阈值控制。这里第三条带是乘在已经算完的 lit 上,不能写成又一项白色镜面高光。
眉毛透视依赖背后分类
分类缓冲 R 的高两位记录表面类别,低六位记录角色编号;头发预分类只写 G/B,保留背后脸或眼睛在 R 中的分类。当前分类为身体 3、脸 1、眼睛 2。
R = (category × 64 + characterId) / 255
hair.G = (128 + characterId) / 255
category = packedR >> 6
id = packedR & 63
刘海基础 alpha 来自底图,还会结合捕获开关、头发方向与视线修形。只有背后是同一角色的眼部类别时,再根据头发轴深度把 alpha 混向透视值:
axisDepth = dot(hairAxisA, ViewMatrix.row2.xyz)
w = S((axisDepth − axisThreshold) / max(0.99 − axisThreshold, 10⁻⁶))
alpha ← lerp(alpha, eyeThroughAlpha, w)
Cout = Chair × alpha + Cbehind × (1 − alpha)
最后一行是 HairBlend Pass 的标准 alpha 混合。源码先双线性采样分类值,再将 R×255 截断为 uint,不能擅自换成四舍五入。HairOpaque 使用模板做互补区域补绘,随后画头发描边。如果描边提前写深度,脸和眉毛根本没有绘到后面,这些 alpha 计算即使正确也看不到透视。
分类打包里的 64、128、63 分别对应位 6 的步长、位 7 的标记与低 6 位掩码;255 把字节编码写入归一化通道。透明公式里的 0.99 是 axisDepth 混合段的终点,直接参与过渡长度,属于当前捕获映射的配置,不能当成通用深度阈值。

查看当前实现 · HairBandTint
Shaders/Includes/AnantaCharacterLighting.hlsl · 第 221 行
float3 HairBandTint(Varyings i, Surface s, Light light, float shade, float3 view)
{
// 68247:645-716,737-749,770,784-785: third band removes overlap
// with both primary lobes and tints the completed lit hair color.
float3 authored=SafeNormalize(_CaptureFaceAxes[0].xyz*_CaptureMaterial[35].x+_CaptureFaceAxes[1].xyz*_CaptureMaterial[35].y+_CaptureFaceAxes[2].xyz*_CaptureMaterial[35].z);
float3 h=SafeNormalize(SafeNormalize(lerp(light.direction,authored,.6))+view);
float3 b=normalize(cross(normalize(i.normalWS),normalize(i.tangentWS.xyz)))*sign(i.tangentWS.w);
float bh=dot(b,h);
float2 noiseUv=i.uv*_CaptureMaterial[12].xy+_CaptureMaterial[12].zw;
float noise=1-2*SAMPLE_TEXTURE2D(_HairNoise,sampler_HairNoise,noiseUv).g;
float viewShift=.1*saturate(dot(authored,view));
float p1=_CaptureMaterial[37].y-bh+noise*_CaptureMaterial[37].z+viewShift;
float p2=_CaptureMaterial[39].w-bh+noise*_CaptureMaterial[40].x+viewShift;
float p3=_CaptureMaterial[42].x-bh+noise*_CaptureMaterial[42].y+viewShift;
float first=HairBand(p1,_CaptureMaterial[36].z,_CaptureMaterial[36].y,_CaptureMaterial[37].x,_CaptureMaterial[36].w);
float second=HairBand(p2,_CaptureMaterial[38].x,_CaptureMaterial[37].w,_CaptureMaterial[38].z,_CaptureMaterial[38].y);
float third=HairBand(p3,_CaptureMaterial[41].x,_CaptureMaterial[41].y,_CaptureMaterial[41].z,_CaptureMaterial[41].w)*(1-saturate(first+second));
float nearEdge=max(_CaptureMaterial[33].w-_CaptureMaterial[34].x,0);
float fade=Smooth01((dot(s.normal,view)-nearEdge)/max(_CaptureMaterial[33].w+.001-nearEdge,1e-6));
return lerp(1,_CaptureMaterial[40].yzw,third*s.mask.b*fade*shade);
}查看当前实现 · AnantaHairFrag
Shaders/Includes/AnantaCharacterPasses.hlsl · 第 130 行
float4 AnantaHairFrag(Varyings i, FRONT_FACE_TYPE facing : FRONT_FACE_SEMANTIC) : SV_Target
{
if (_UseCaptureConstants<.5 || _MaterialType<2.5 || _MaterialType>3.5) clip(-1);
float4 color=AnantaFrag(i,facing);
float alpha=SAMPLE_TEXTURE2D(_BaseMap,sampler_BaseMap,i.uv).a;
if (asuint(_CaptureMaterial[325].w)!=0) alpha=saturate(alpha*2);
alpha=1+_CaptureMaterial[13].w*(alpha-1);
float3 view=SafeNormalize(GetWorldSpaceViewDir(i.positionWS));
float2 projected=float2(dot(cross(_CaptureHairAxisA.xyz,_CaptureHairAxisB.xyz),view),dot(_CaptureHairAxisB.xyz,view));
float projectionLength=length(projected);
float scaledLength=length(projected/(projectionLength+.001)*_CaptureMaterial[14].zw);
alpha=lerp(alpha,1,Smooth01((projectionLength-.7*scaledLength)/max(.3*scaledLength,1e-6)));
// Source s2 is bilinear/clamp and ftou truncates interpolated category bytes.
uint packed=(uint)(SAMPLE_TEXTURE2D(_AnantaHairClassification,sampler_LinearClamp,GetNormalizedScreenSpaceUV(i.positionCS)).r*255);
if ((packed>>6)==2 && (packed&63)==(uint)_CaptureCharacterId)
{
float axisDepth=dot(_CaptureHairAxisA.xyz,UNITY_MATRIX_V[2].xyz);
float weight=Smooth01((axisDepth-_CaptureMaterial[14].y)/max(.99-_CaptureMaterial[14].y,1e-6));
alpha=lerp(alpha,_CaptureMaterial[14].x,weight);
}
color.a=alpha;
return color;
}09描边:从平滑法线到投影外扩
两种宽度路径
描边是额外的外扩几何绘制,使用辅助平滑法线。它不是从最终颜色里做一次屏幕边缘检测。辅助法线先在切线空间解码,再经 TBN 转世界方向;可选八面体编码还有折叠恢复分支。
原捕获宽度随距离、投影和顶点色变化:
wCaptured = GlobalWidth × OutlineWidth × 0.002
× sqrt(|viewZ| / |P00|) × (2 × vertexColor.g)
后来加入的屏幕尺度模式,用输入分辨率高度 H 和投影矩阵 P11,把像素宽度转换成视空间挤出距离:
捕获宽度里的 0.002 是旧宽度参数到外扩量的尺度系数,原始调参依据未确认。2×vertexColor.g 让顶点色 0.5 对应 1 倍宽度。下面屏幕尺度公式的 2/H 则可由投影推导:NDC 的纵向跨度是 2,H 个像素因此对应每像素 2/H;它与捕获式的 0.002 不是同一种来源。
wndc = 2 × PixelWidth × GlobalWidth × (2 × vertexColor.g) / H
wview = wndc × |clipOriginal.w| / max(|P11|, 10⁻⁶)
clipExpanded = P × (positionVS + smoothDirectionVS × wview, 1)
clipOut.xyz = (clipExpanded.xyz / clipExpanded.w) × clipOriginal.w
clipOut.w = clipOriginal.w
PixelWidth 控制轮廓尺度,仍保留法线投影的缩短。它不是强制每条内部线都恰好 1 像素;输入 1080p 的 1 像素尺度,也不等于最终 1440p 输出的 1 像素。
早期我把投影方向归一化,小到几乎没有屏幕分量的嘴唇法线也被撑足线宽,嘴线就变成两侧的小黑点。现在宽度和深度来自同一个挤出点,XYZ 统一投回 NDC,再保留原来的 clip.w 做插值。身体和头发还有各自明确的深度偏置,继续在这条路径上保留。
描边颜色也有自己的换色、HSV 饱和度/色相修形、环境光和主光摄像机朝向项。它并不是统一纯黑;OutlineLighting 保留捕获分支的常数环境项以及平方朝向光照,再乘美术 Tint 与预曝光。

查看当前实现 · OutlineVert
Shaders/Includes/AnantaOutlineGeometry.hlsl · 第 39 行
OutlineVaryings OutlineVert(OutlineAttributes v)
{
OutlineVaryings o;
// VS 104703/104715/104727: TEXCOORD2 encodes a tangent-space
// smooth normal, not a displacement in model or screen space.
float2 xy = v.auxiliaryNormal * 2 - 1;
float3 smoothNormal = float3(xy, sqrt(saturate(1 - dot(xy, xy))));
if (_OutlineOctahedral != 0)
{
smoothNormal = float3(xy, 1 - abs(xy.x) - abs(xy.y));
float fold = saturate(-smoothNormal.z);
smoothNormal.xy += float2(xy.x >= 0 ? -fold : fold, xy.y >= 0 ? -fold : fold);
smoothNormal = normalize(smoothNormal);
}
float3 normalWS = TransformObjectToWorldNormal(v.normalOS, false);
float3 tangentWS = TransformObjectToWorldDir(v.tangentOS.xyz, false);
float3 bitangentWS = cross(normalWS, tangentWS) * v.tangentOS.w;
float3 directionWS = normalize(smoothNormal.x * tangentWS + smoothNormal.y * bitangentWS + smoothNormal.z * normalWS);
float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
if (_OutlineScreenSpace > 0.5)
{
float3 positionVS = TransformWorldToView(positionWS);
float4 originalCS = mul(UNITY_MATRIX_P, float4(positionVS, 1));
o.positionCS = originalCS;
if (abs(originalCS.w) > 0.000001)
{
float3 directionVS = mul((float3x3)UNITY_MATRIX_V, directionWS);
float2 viewport = max(GetScaledScreenParams().xy, float2(1, 1));
// Pixel width sets the silhouette scale. Preserve the projected
// normal's foreshortening and the same extrusion's depth: forcing
// tiny face-normal projections to full width distorts inner lips.
float ndcWidth = 2 * _OutlinePixelWidth * _OutlineGlobalWidth * (2 * v.color.g) / viewport.y;
float viewWidth = ndcWidth * abs(originalCS.w) / max(abs(UNITY_MATRIX_P._m11), 0.000001);
float4 expandedCS = mul(UNITY_MATRIX_P, float4(positionVS + directionVS * viewWidth, 1));
if (abs(expandedCS.w) > 0.000001)
{
// Map back using the original w, preserving the NDC path's
// surface interpolation while updating both XY and depth.
o.positionCS.xyz = (expandedCS.xyz / expandedCS.w) * originalCS.w;
}
if (_MaterialType > 2.5 && abs(positionVS.z) > 0.000001)
{
float bias = (saturate(v.color.r - 1.0 / 255.0) * 2 - 1) * _OutlineVertexDepthBias;
float3 biasedVS = positionVS * ((positionVS.z - bias * 0.005) / positionVS.z);
float4 biasedCS = mul(UNITY_MATRIX_P, float4(biasedVS, 1));
// Preserve the explicit hair bias, but leave the screen offset
// and original clip.w independent of inflated-hull depth.
if (abs(biasedCS.w) > 0.000001)
o.positionCS.z = (biasedCS.z / biasedCS.w) * originalCS.w;
}
}
}
else
{
float viewZ = TransformWorldToView(positionWS).z;
float width = _OutlineGlobalWidth * _OutlineWidth * 0.002;
width *= sqrt(abs(viewZ) / abs(UNITY_MATRIX_P._m00)) * (2 * v.color.g);
float3 expandedVS = TransformWorldToView(positionWS + directionWS * width);
if (_MaterialType > 2.5)
{
float bias = (saturate(v.color.r - 1.0 / 255.0) * 2 - 1) * _OutlineVertexDepthBias;
expandedVS *= (expandedVS.z - bias * 0.005) / expandedVS.z;
}
o.positionCS = mul(UNITY_MATRIX_P, float4(expandedVS, 1));
}
o.clipDistance = _OutlineEnabled < 0.5 ? -1 : 1;
if (_MaterialType < 0.5)
{
o.positionCS.z -= _OutlineDepthBias * 0.001 * o.positionCS.w;
float4 group = float4(v.color.a >= 0 && v.color.a < 0.28,
v.color.a >= 0.28 && v.color.a < 0.51,
v.color.a >= 0.51 && v.color.a < 0.79,
v.color.a >= 0.79 && v.color.a < 0.98);
if ((dot(group, _OutlineClipThresholds) >= v.color.a && v.color.a <= 0.98) || _OutlineDisabled != 0)
o.clipDistance = -1;
}
o.uv = float2(v.uv.x, lerp(v.uv.y, 1 - v.uv.y, _FlipV));
return o;
}10皮肤散射与 4× MSAA
只替换漫反射分量
皮肤散射缓冲 RGB 存 ShadeDiffuse 的结果乘预曝光和散射强度,A 则是离散 profile 编号 8/255。它不是“8/255 的散射混合比例”。脸与身体皮肤行共享这条输出。
滤波先按深度恢复距离和像素尺度,再用最多 20 个重要性采样。当前 profile 8 的 RGB 衰减系数为 (100,346.1864319,324.8656921),深度变化也进入每个样本的距离。
Δz = (LinearDepth(sample) − LinearDepth(center)) × millimetersPerUnit
d = sqrt(radius² + Δz²)
e = 2^(−0.480898 × d × decayRGB)
weight = e × (1 + e²) × (0.039789 × decayRGB) × inversePdf
Cblur = Σ(Csample × weight) / max(Σweight, 10⁻²⁰)
Cscene′ = max(Cscene + Cblur − CoriginalDiffuse, 0)
weight 按 RGB 通道计算。半径由原分支的重要性采样生成,角度用黄金比例序列加像素/深度哈希旋转,inversePdf 抵消不均匀取样带来的权重偏差。同 profile、有效散射像素才参与;越界样本跳过。当前捕获投影的深度解码常量也固定在 ScatterLinearDepth 中,换相机投影不能只沿用这两个数字。
SSS 的小数可以写回什么
令距离为 d、通道衰减系数为 s,则 2^(−0.480898ds)≈exp(−ds/3),因为 0.480898≈log₂(e)/3。于是 e(1+e²)=exp(−ds/3)+exp(−ds),不是三组随意组合的指数。0.039789≈1/(8π) 对应扩散剖面的归一化系数;这个双指数结构可以对照 pbrt 的 Burley 扩散剖面。当前屏幕空间实现还结合了半径采样、inversePdf 和最终逐通道归一化,不能只截取这一行就声称完整实现了物理 BSSRDF。
(100,346.1864319,324.8656921) 则是事件 17599 的 cb2[8].xyz,属于 profile 8 的实际配置,不能从上述归一化公式推出。系数越大,距离上的衰减越快;在当前计算尺度下,绿色与蓝色衰减比红色快。它不是所有皮肤都该使用的一组 RGB 数值。
| 其他数字 | 作用与来源 |
|---|---|
| 0.01 | 当前 profile 的采样半径尺度,对应 cb2[8].w;需要与距离单位配套 |
| 2.079442、4.158883 | 约为 3ln2、6ln2,把 log₂ 换成自然对数形式 |
| 25.132742、6.283185 | 约为 8π、2π,分别参与 inversePdf 与完整角度旋转 |
| 0.618034 | 黄金比例的共轭近似值,用于分散样本角度;不是散射颜色参数 |
| 20 | cb0[44].w 指定的样本上限;不是无限精度积分 |
| 3.5、1、25 | cb0[45] 的距离与半径尺度控制,决定何时从 1 倍走向 25 倍 |
| 6.1537795067、0.000066757202 | cb0[14].zw 的投影深度解码;它们属于捕获相机,换近远平面需要重算 |
这套方法使用了扩散模型的近似,再落到屏幕空间采样。公开模型、抓帧配置和相机系数需要分开,不能统称“经验算法”。
Cscene + Cblur − CoriginalDiffuse 的意义是把原来的漫反射换成滤波结果,保留高光等其他部分。我没有把整个角色颜色模糊一遍。
多重采样里,连续值和类别值分开处理
直接 resolve RGBA 会让半覆盖像素的 profile 8 平均成 profile 4。正式 MSAA 路径只平均有效皮肤样本的 RGB,类别从最近的有效样本保留,覆盖率单独存储。
nskin = 有效皮肤样本数
Cresolved = Σvalid Csample / max(nskin, 1)
coverage = nskin / MSAA_sample_count
profile = 最近有效皮肤样本的 profile
SSS 采样的 weight 再乘被采样像素的 coverage。滤波结果回写时,用 SV_SampleIndex 判断每个样本是否为皮肤,只对有效样本加上 Cblur−CsampleDiffuse,随后再做颜色解析。
原深度与模板也要继续保留,刘海阴影会用到它们。资源重建时,我补了与 MRT 匹配的单采样深度附件,解决实际连续帧中解析缓冲残留和皮肤发黑的问题。

查看当前实现 · FragScatterBlur
Shaders/AnantaPost.shader · 第 85 行
float4 FragScatterBlur(Varyings i) : SV_Target
{
int2 pixel = int2(i.texcoord * _AnantaScatterSize.zw);
float4 center = LOAD_TEXTURE2D(_BlitTexture, pixel);
if (center.b <= 0) return 0;
uint profile = min((uint)(center.a * 255 + .5), 15u);
// Every active character pixel in event 17599 selects profile 8.
// Other profiles need their own capture data, not this character's kernel.
if (profile != 8u) return float4(center.rgb, 1);
float deviceDepth = ScatterDeviceDepth(pixel);
float centerDepth = ScatterLinearDepth(deviceDepth);
float distanceFade = saturate(centerDepth / 3.5);
distanceFade *= distanceFade * (3 - 2 * distanceFade);
float radiusScale = 1 + 24 * distanceFade; // cb0[45]=(1,25,3.5,0)
float millimetersPerUnit = 1000 / radiusScale;
float homogeneousW = 6.1537799835 * deviceDepth + 0.000066389694;
float worldPixel = max(2 * .3941239119 * _AnantaScatterSize.x / homogeneousW, .0001);
float pixelsPerMillimeter = rcp(millimetersPerUnit * worldPixel);
// cb2[32].xy=(1,.1656437665), cb0[44].w=20.
uint count = min((uint)(pow(pixelsPerMillimeter * .1656437665, 2) * .785398), 20u);
if (count < 1u) return float4(center.rgb, 1);
uint hash = ScatterHash((uint)(deviceDepth * 16777216));
hash = ScatterHash(hash ^ (uint)pixel.y);
hash = ScatterHash(hash ^ (uint)pixel.x);
float rotation = (asfloat((hash & 0x007fffffu) | 0x3f800000u) - 1) * 6.283185;
float rotationSin, rotationCos;
sincos(rotation, rotationSin, rotationCos);
float3 sum = 0, weightSum = 0;
const float3 decay = float3(100, 346.1864319, 324.8656921); // cb2[8].xyz
[loop] for (uint sampleIndex = 0u; sampleIndex < count; sampleIndex++)
{
float u = 1 - (sampleIndex + .5) / count;
float fourU = 4 * u;
float q = 1 + fourU * (2 * u + sqrt(1 + fourU * u));
float reciprocalCubeRoot = exp2(log2(q) * (-1.0 / 3.0));
float h = reciprocalCubeRoot * reciprocalCubeRoot * q + reciprocalCubeRoot + 1;
float radius = (log2(h) * 2.079442 - (log2(u) * 2.079442 + 4.158883)) * .01;
float inversePdf = h * h * h / ((fourU * fourU + h * h) * fourU) * 25.132742 * .01;
float angle = frac(sampleIndex * .618034) * 6.283185;
float angleSin, angleCos;
sincos(angle, angleSin, angleCos);
float2 direction = float2(rotationCos * angleCos - rotationSin * angleSin,
rotationSin * angleCos + rotationCos * angleSin);
int2 samplePixel = pixel + int2(round(direction * (pixelsPerMillimeter * radius)));
if (any(samplePixel < 0) || any(samplePixel >= int2(_AnantaScatterSize.zw))) continue;
float4 value = LOAD_TEXTURE2D(_BlitTexture, samplePixel);
uint sampleProfile = value.a > 0 ? min((uint)(value.a * 255 + .5), 15u) : profile;
if (value.b <= 0 || sampleProfile != profile) continue;
float depthDelta = (ScatterLinearDepth(ScatterDeviceDepth(samplePixel)) - centerDepth) * millimetersPerUnit;
float distance = sqrt(radius * radius + depthDelta * depthDelta);
float3 e = exp2(-.480898 * distance * decay);
float3 weight = e * (1 + e * e) * (.039789 * decay) * inversePdf;
if (_AnantaMsaaScatter > .5) weight *= LOAD_TEXTURE2D(_AnantaScatterCoverageDepth,samplePixel).r;
sum += value.rgb * weight;
weightSum += weight;
}
return float4(sum / max(weightSum, 1e-20), 1);
}11后置刘海阴影的颜色乘法
SSS 合成之后,Game 路径绘制捕获的两片阴影几何。分类必须是同一角色的脸/眼睛,G 高位还会排除可见头发;模板位 32 在合适的阶段清理、替换,阴影不写深度。
mask = saturate(ShadowMask.r × maskScale + maskOffset)
factor = match ? shadowTint × (1 − mask) : (1, 1, 1)
Cdestination ← Cdestination × factor
最后的乘法来自 Blend Zero SrcColor,不是通常那种在白色和 Tint 之间 lerp 的阴影公式。绘制区域、遮罩、模板和分类共同限定实际影响范围。
这些网格来自捕获姿态;Scene View 另外调度同一份资源,Game 则在 SSS 后、曝光前处理。现在它能解释当前姿态下的刘海投影,但还不是随角色动画和任意场景灯光变化的动态阴影方案。
12局部曝光:按亮度分布修正,而不是统一提亮
AnantaLocalExposure 将图像分块,建立 32 个亮度 bin 的三维网格。每个 bin 保存对数亮度的加权和与权重,另外生成低频对数亮度图。
Y = max(((R+G+B)/3) × 5.656849, 0.0009765625)
ℓ = log₂(Y)
zGrid = ((ℓ/30 + 1/3) × 31 + 0.5) / 32
ℓbin = binWeight < 0.001 ? ℓlow : binSum / binWeight
ℓlocal = 0.4ℓbin + 0.6ℓlow + log₂(0.17677686)
dark = min(ℓlocal − log₂(0.36), 0)
gain = lerp(2^(−0.3dark), 2^(−0.1dark), characterMask.a)
× (0.17677686 × 5.656849) × 2^ExposureOffset
Cout = Cin × gain
ℓlow 是两方向滤波后的低频亮度。这里统计用的是 RGB 均值乘捕获常量,而不是上面光照使用的 Luma。角色遮罩使曝光增益不同,不能把整幅画面乘一个固定系数来代替。
5.656849 与 0.17677686 的乘积约为 1,对应去掉与恢复捕获预曝光尺度;两者不是两次独立提亮。亮度下限 0.0009765625=2⁻¹⁰ 防止取零的对数。32 是亮度网格的层数,31 是最后一个层索引,0.5 是 texel 中心偏移;ℓ/30+1/3 把对数亮度约 −10 到 20 映射进 0–1。
0.4、0.6 决定网格亮度和低频亮度的混合比例;0.36 是只提升暗区的参考亮度,不是标准中灰。−0.3、−0.1 决定背景与角色暗部提亮的斜率。若 dark 为 −1、ExposureOffset 为零,两个分支的增益约为 1.231 和 1.072。这个例子只展开公式;这些捕获配置的原始选值依据未确认。
角色前向输出已经乘 _CapturePreExposure。预曝光、局部曝光和最后 LUT 的输入域要一起对上,早期遗漏预曝光、重复颜色转换或先把 HDR 截成 8 位,都造成过大面积配色错误。
13时域重建:运动、历史裁剪与 Stable
先保证本帧输入正确
当前运动准备在 3×3 邻域选择最近的深度,再读取那个像素的运动向量。当前 reversed-Z 下,数值更大表示更近,相等时保留原来的取样顺序。减去相机抖动贡献后,主重建里的历史坐标是:
motion = preparedMotion.xy − jitterMotionCorrection
uvHistory = uvCurrent − motion
只有历史有效、坐标在范围内才允许取历史。遮挡检测用当前帧深度重投影生成覆盖/深度信息,不是简单拿一张上一帧深度图。相机切换、分辨率变化或 Captured/Stable 切换都要重置历史。
颜色先变换到亮度/色差空间
RGB′ = max(RGB, 0) × 0.10414039
Y = 0.25R′ + 0.5G′ + 0.25B′
Co = 0.5(R′ − B′)
Cg = −0.25R′ + 0.5G′ − 0.25B′
Tcompressed = T / (1 + max(R′,G′,B′))
T=(Y,Co,Cg)。这个亮度定义用于时域空间,和光照 Luma、曝光统计都不同。重建在未压缩空间估计邻域矩,混合在压缩空间完成,再做逆转换;不能把几种颜色域的误差直接混在一起比较。
Captured 与 Stable 的核
Captured 当前颜色使用 3×3 径向多项式核,历史颜色则用 4×4 Lanczos2,历史结果再限制到中心 2×2 的上下界。当前核可能有负权重,这些负瓣能保细节,也会在静态细线附近留下明暗振铃。
t = min(dot(δ,δ), 4),δ=(samplePixel−targetPixel)×radius
wCaptured = (0.25t−1)² × [1.5625(0.4t−1)² − 0.5625]
wMoment = 2^(−3.318198 × |samplePixel−targetPixel|²)
wStable = wMoment
Stable 只替换当前重建的权重,并同步改变累计权重的分子;历史采样、运动输入和拒绝仍走原流程。它是我为这个静态姿态加入的预览质量选项,不是对源核公式的纠错。图里的曲线按 radius=1 展示,实际 Captured 的 δ 会乘 radius,Stable 使用未乘 radius 的像素偏移。
重建核为什么有 1.5625 和 3.318198
Captured 的几个小数可写成 0.25=1/4、0.4=2/5、1.5625=25/16、0.5625=9/16。它与 AMD FSR1 公开源码中的 Lanczos2 多项式近似 同形:内部一项决定正负权重,外部平方窗让距离到 2 像素时衰减到零。确认了核形状,并不意味着这套时域流程就是完整的 FSR1;FSR1 的 EASU 本身是空间重建。
2^(−3.318198d²)≈exp(−2.3d²),可以理解为标准差约 0.466 像素的高斯权重。该数值已经出现在捕获的 moment 核里。我的 Stable 选择是把这条原本用于统计的非负权重也用于当前颜色重建,减少静态细线的负权重振铃;不是重新拟合出了 3.318198。原作者选择这个核宽的依据未确认。
Stable 会让脸部和衣服细节稍柔和,它影响整个选中的 Game 相机,Scene View 不经过这套后处理。工程保留 Captured,方便继续对照。
按 radius=1 展开源码中的当前颜色核。曲线是权重演示,不是重新测量的画面效果。
历史裁剪与混合
用 wMoment 求邻域均值 μ 和二阶矩,得到各颜色分量的标准差 σ,再构造椭球范围。Y 方向比两个色差方向更宽。
σ = sqrt(abs(E[C²] − μ²))
extent = max(σ × (1.7q, q, q), 10⁻⁵)
ρ = length((Chistory − μ) / extent)
Cclip = ρ > 1 ? μ + (Chistory−μ)/ρ : Chistory
Cclip ← Cclip + confidence × (Chistory−Cclip)
q 来自运动、方差和拒绝遮罩;confidence 由亮度置信度与历史锁定信号结合覆盖情况得到,负责放松裁剪。它没有再乘进最终混合分母。
1.7 让 Y 亮度轴上的允许偏差比 Co/Cg 两个色差轴宽 70%,是在捕获中保留的裁剪配置。它是椭球的轴比例,不是高斯标准差必然要求的比值。
currentNumerator = uncovered ? 1 : Σw × 0.04625
historyWeight = uncovered ? 0 : survival
a = currentNumerator / max(currentNumerator + historyWeight, 0.000061)
Tnext = lerp(Compress(Cclip), Compress(Ccurrent), a)
survival 来自有效覆盖 alpha 和运动长度:min(alpha+saturate(2×motionPixels)×(0.15−alpha),alpha),主混合的 motionPixels 保留捕获尺度 length(motion×(inputSize×2))。这不是每帧固定按 10% 当前颜色和 90% 历史混合。
0.04625 调整当前核总权重相对于历史权重的份量,是原 Shader 30159 的字面量,不是固定的最终混合比例。假设核权重和为 1、survival 为 1,则当前帧份量约为 0.04625/1.04625=4.42%;真实结果还随权重和与 survival 变化。0.15 是运动时历史存活权重趋向的参考值:对于大于 0.15 的覆盖权重,运动会压低它;外层 min 又避免低覆盖区域被抬高。这些选值的原始调参依据未确认。
0.10414039 来自捕获时域链选用的曝光分量 X,作用是把 HDR 输入缩放到颜色压缩与裁剪的工作尺度,逆变换再除回同一尺度。它不是亮度/色差矩阵的系数,也不是我新增的亮度补偿。YCoCg 的 1/4、1/2 则来自变换矩阵,可由后面的 RGB 逆变换检查是否配对。
亮度置信度另外保存四帧亮度,比较当前与历史的 min(a,b)/max(a,b),在同方向变化中找更匹配的历史值;仅有改善且历史满足条件时生成置信度,再按运动和拒绝信号衰减。细节锁定值还参与历史 alpha 更新,随后可能归零,不能把最后看到的零误认为它未被使用。

查看当前实现 · FragTemporal
Shaders/AnantaPost.shader · 第 250 行
float4 FragTemporal(Varyings i) : SV_Target
{
float2 sampleUV = i.texcoord + _AnantaJitter.xy * _AnantaSourceSize.xy;
if (_AnantaPostParams.z >= 1) return float4(ReadColor(sampleUV), 0);
int2 motionPixel = clamp((int2)(i.texcoord * _AnantaMotionSize.zw), int2(0, 0), int2(_AnantaMotionSize.zw) - 1);
float2 motion = LOAD_TEXTURE2D(_AnantaPreparedMotion, motionPixel).rg - _AnantaMotionJitter.xy;
float2 historyUV = i.texcoord - motion;
bool historyValid = _AnantaPostParams.w > .5 && all(historyUV >= 0) && all(historyUV <= 1);
float4 mask = _AnantaHasTemporalRejection > 1.5
? SAMPLE_TEXTURE2D(_AnantaRejectionRgba, sampler_AnantaRejectionRgba, sampleUV)
: float4(0, _AnantaHasTemporalRejection > .5
? SAMPLE_TEXTURE2D(_AnantaTemporalRejection, sampler_AnantaTemporalRejection, sampleUV).r : 0,
0, historyValid ? 1 : 0);
float alpha = round(mask.a * 100) > 1 ? mask.a : 0;
bool uncovered = alpha == 0;
float motionPixels = length(motion * (_AnantaSourceSize.zw * 2));
float survival = min(alpha + saturate(2 * motionPixels) * (.15 - alpha), alpha);
float radius = 1 + (1.0 / .75 - 1) * min(5 * survival, min(1 - mask.b, 1 - .5 * mask.g));
float2 pixel = i.texcoord * _AnantaSourceSize.zw + _AnantaJitter.xy - .5;
// DXBC round_ni at 30159:241 floors the unjittered input position.
float2 centerPixel = floor(i.texcoord * _AnantaSourceSize.zw);
float3 sum = 0, mean = 0, secondMoment = 0, low = 1e20, high = -1e20;
float weight = 0, momentWeight = 0;
[unroll] for (int y = -1; y <= 1; y++)
[unroll] for (int x = -1; x <= 1; x++)
{
float2 p = centerPixel + float2(x, y);
float3 c = ToTemporalColor(ReadColor((p + .5) * _AnantaSourceSize.xy), uncovered);
float2 delta = (p - pixel) * radius;
float distance2 = min(dot(delta, delta), 4);
// Captured 30159:567-579 radial reconstruction and moment kernels.
float polynomial = 1.5625 * pow(.4 * distance2 - 1, 2) - .5625;
float w = pow(.25 * distance2 - 1, 2) * polynomial;
float2 momentDelta = p - pixel;
float mw = exp2(-3.318198 * dot(momentDelta, momentDelta));
// Optional preview filter: source negative lobes can retain dark
// ringing on a static captured pose. Keep source mode unchanged.
if (_AnantaStableTemporalReconstruction > .5) w = mw;
bool validTap = all(p >= 0) && all(p < _AnantaSourceSize.zw);
if (validTap)
{
if (!uncovered) { sum += c * w; weight += w; }
mean += c * mw; secondMoment += c * c * mw; momentWeight += mw;
}
low = min(low, c); high = max(high, c);
}
float3 current = uncovered ? DecompressTemporal(mean / max(momentWeight, 0.000061))
: clamp(sum / max(weight, 0.000061), low, high);
if (!historyValid) return float4(FromTemporalColor(CompressTemporal(current)), 0);
float4 historyRGBA = ReadHistory(historyUV);
float3 history = ToTemporalColor(historyRGBA.rgb, false);
mean /= momentWeight;
float3 deviation = sqrt(abs(secondMoment / momentWeight - mean * mean));
float luminanceConfidence = _AnantaHasTemporalRejection > 1.5
? SAMPLE_TEXTURE2D(_AnantaLumaConfidence, sampler_AnantaLumaConfidence, i.texcoord).r : 1;
float varianceMask = _AnantaHasTemporalRejection > 1.5
? SAMPLE_TEXTURE2D(_AnantaVarianceControl, sampler_AnantaVarianceControl, sampleUV).r : 0;
float maxMask = max(mask.r, max(mask.g, mask.b));
float reactive = saturate(max(historyRGBA.a - 2 * maxMask, 0) - 1);
float confidence = min(1, max(luminanceConfidence, reactive) * alpha * (1 - mask.g));
float varianceFactor = 3 - 2 * max(saturate(motionPixels * .05),
max(saturate(.75 - .05 * varianceMask), max(sqrt(mask.r), max(mask.b, 1 - mask.a))));
float3 extent = max(deviation * float3(1.7 * varianceFactor, varianceFactor, varianceFactor), 0.00001);
float3 offset = (history - mean) / extent;
float distance = length(offset);
float3 clipped = distance > 1 ? mean + (history - mean) / distance : history;
clipped += confidence * (history - clipped);
float currentNumerator = uncovered ? 1 : weight * .04625;
float historyWeight = uncovered ? 0 : survival;
float currentWeight = currentNumerator / max(currentNumerator + historyWeight, .000061);
float3 blended = lerp(CompressTemporal(clipped), CompressTemporal(current), currentWeight);
float lockValue = LOAD_TEXTURE2D(_AnantaLock, int2(i.positionCS.xy)).r;
float nextAlpha = max(min(max(historyRGBA.a - 2 * maxMask, 0) + lockValue * (1 - mask.r), 2)
- (.1 / 14) * (1 - maxMask), 0);
nextAlpha *= all(i.texcoord + motion >= 0) && all(i.texcoord + motion <= 1) ? 1 : 0;
return float4(FromTemporalColor(blended), nextAlpha);
}14锐化、运动模糊、Bloom 和 LUT
RCAS 不是再叠一层普通锐化
FragSharpen 使用当前像素与上下左右四邻域,根据亮度变化、邻域最小最大值限制负瓣。设实际限幅和噪声抑制后得到的负瓣为 k:
Csharp = (C + k(N+S+E+W)) / (1+4k)
k = clamp(max(limit.r,limit.g,limit.b), −0.1875, 0)
× sharpenStrength × noise
源码在预曝光尺度内计算,再除回该尺度,并保留捕获使用的近似倒数加一次 Newton 迭代。限制 k 的上下界是为了控制过冲,不能只把 strength 拉高来补前面丢失的纹理细节。
0.1875=3/16=0.25−1/16,可对照 AMD RCAS 的负瓣限制。四个邻居时,分母 1+4k 在 k=−0.25 会变成零,−0.1875 给它留出余量;公开实现还把这个限制用于控制不自然的锐化。这里也用了捕获的极值与噪声限制,并非只要把分母设正就能保证没有过冲。
运动模糊仍在链里:从本帧运动向量扣除抖动,乘强度并限制 UV 偏移,再沿方向取样。静止相机和静止捕获网格不会凭空产生动画运动;当前只恢复了静态网格,也不能拿原角色的运动向量硬套上去。
六级 Bloom 和材质遮罩
Bloom 先按辅助缓冲控制饱和度与阈值,不能让衣服、头发和皮肤全部按同一个亮度门槛发光。核心的软阈值是:
threshold = 0.5 × (1−mask.a) × bloomThreshold
peak = max(Cr,Cg,Cb)
knee = clamp(peak−0.5threshold, 0, threshold)
contribution = max(peak−threshold, knee²/(2threshold+0.0001)) / max(peak,0.0001)
后面还按 mask.b 混合贡献,再压缩高亮并乘 abs(1−10×mask.g)。六级降采样各有捕获的模糊核,从小图往回合成,最终用四次双线性采样实现三次 B 样条重建,减少直接放大粗分辨率带来的台阶。
Bloom 的 0.5 把 soft knee 放在阈值两侧,0.0001 保护阈值与亮度为零时的除法;后面的 10×mask.g 则放大材质遮罩的影响,是捕获控制。六级是当前链的层数,改层数会改变空间尺度与能量分配,不是换个数字就保持同样的 Bloom。
LUT 的输入是对数编码
最终把场景和 Bloom 加起来,编码为 32³ LUT 的采样坐标。每个坐标都要对齐 texel 中心。
C = max(Cscene + Cbloom × bloomIntensity, 0)
encoded = saturate(log₂(C+0.002668)/14 + 0.610727)
UVW = encoded × (31/32) + 0.5/32
graded = lerp(LinearToSRGB(saturate(C)), saturate(LUT(UVW)×1.05), lutIntensity)
Clinear = SRGBToLinear(graded)
这不是把线性 HDR 颜色直接当 UVW,也不是替换成默认 ACES。LUTIntensity=1 时选择 LUT 结果;如果 LUT 没准备好,代码走明确的基础转换回退。
LUT 编码的三个小数不是独立调色旋钮
我在本地 UE 源码的 GammaCorrectionCommon.ush 中找到纯对数转换:动态范围 14 档、线性灰点 0.18、编码灰点 444/1023。PostProcessTonemap.usf 还把 LogToLin(0) 加到线性颜色后再编码。把这两步展开:
offset = 444/1023 − log₂(0.18)/14 ≈ 0.610726965
blackOffset = 0.18 × 2^(−14 × 444/1023) ≈ 0.002667719
encoded = saturate(log₂(C + blackOffset)/14 + offset)
这与捕获指令中的 0.610727、0.002668、1/14 在舍入精度内对应。因此可以解释它们是由灰点与编码范围组合得到的值;这个代数对应并不能单独证明原游戏调用了哪一个 UE 函数。这里也不是直接使用 Cineon 或 SLog 的完整编码曲线。
32³ LUT 的 31/32 和 0.5/32 则把输入的两端放到第 0 与第 31 个 texel 的中心。1.05 是采样后保留的 5% 增益,和坐标编码无关,属于捕获的输出控制,原始选值依据未确认。
暗角在回到线性域后处理:
edge = saturate(abs(uv−center) × intensity − (0.6,0.2))
vignette = saturate(1−dot(edge,edge))
Clinear ← Clinear × lerp(1, lerp(vignetteColor,1,vignette), vignetteStrength)
最终写回线性的相机中间缓冲,由 URP 最终 blit / sRGB 目标完成一次显示编码。我把这几个阶段分开检查,避免 Shader 内和 RenderTarget 各编码一次。
暗角里的 (0.6,0.2) 分别决定水平、垂直方向从哪里开始衰减,和中心、强度一起定义椭圆式外观。这是屏幕上的构图控制,没有镜头结构或真实光学暗角参数参与。
全文的 10⁻⁵、10⁻⁶、10⁻⁸、10⁻²⁰ 等小量主要用于除法、归一化和取对数的保护。0.000061 接近半精度最小正规数 2⁻¹⁴,但仅凭“接近”不能确认原作者就是按该规则选的;当前能确认的是它在时域分母里的下限作用。这些数字并不是统一的误差容许值,不能随手换成一个全局 epsilon。
查看当前实现 · FragFinal
Shaders/AnantaPost.shader · 第 444 行
float4 FragFinal(Varyings i) : SV_Target
{
float2 uv = i.texcoord;
float3 scene = ReadColor(uv);
float3 bloom = ReadBloom(uv);
float3 c = max(scene + bloom * _AnantaBloomParams.y, 0);
float3 encoded = saturate(log2(c + 0.002668) / 14.0 + 0.610727);
float3 lutUVW = encoded * (31.0 / 32.0) + (0.5 / 32.0);
float3 graded = LinearToSRGB(saturate(c));
if (_AnantaLutReady > 0.5)
graded = lerp(graded, saturate(SAMPLE_TEXTURE3D(_AnantaLut, sampler_AnantaLut, lutUVW).rgb * 1.05), saturate(_AnantaLutIntensity));
float3 linearColor = SRGBToLinear(graded);
// Captured cb0[74..77]: black, aspect 1; center .5; intensity 1.2;
// exponent 1; roundness 0; offsets (.6,.2); identity rotation.
float2 edge = saturate(abs(uv - _AnantaVignetteParams.zw) * _AnantaVignetteParams.x - float2(0.6, 0.2));
float vignette = saturate(1 - dot(edge, edge));
linearColor *= lerp(1.0.xxx, lerp(_AnantaVignetteColor.rgb, 1.0.xxx, vignette), _AnantaVignetteParams.y);
// The feature writes the scene-linear camera intermediate. URP's
// final blit / sRGB render target performs the display encoding once.
return float4(linearColor, 1);
}15我是怎样把错误缩小到具体步骤的
第一张图只证明能画出网格。颜色、描边、透明和后处理都还不对,后面每一轮都固定机位,只改一项,再看它有没有改善对应部位。

| 看到的问题 | 查到的原因 | 实际处理 |
|---|---|---|
| 角色只剩黑色轮廓 | 单 submesh 没按预期绘制主体材质 | 先分开主体与描边,整理后再接回独立 Pass |
| 大面积配色异常 | 材质表误读、PNG 重复转换、预曝光遗漏、HDR 截断 | 回到原生 DDS 与捕获参数,逐段核对 |
| 主工程仍是旧图 | 验证工程更新,场景和材质未完整同步 | 同步后重新打开实际主场景验收 |
| 换电脑场景为空 | 新阴影对象生成了无效编号和引用 | 修复场景序列化,再从重新导入开始验证 |
| 眉毛没有透出来 | 刘海描边提前写深度,脸部没画进来 | 调整脸、透明头发和描边的先后顺序 |
| 运动时历史不对 | 后处理早于本帧运动向量生成 | 放到运动向量之后,再查拒绝与反馈 |
| 描边变粗但误差增大 | 加粗隐藏了断续,也偏离参考线宽 | 恢复尺度,继续检查时域与几何覆盖 |
| 鞋带和衣服纹理偏软 | 各向异性 0 被导入器改成 1,又受质量设置影响 | 保留捕获采样约定,重导入验证 |
| 嘴线变成两个黑点 | 小投影法线被强制撑足宽度,深度还停在原表面 | 使用同一个视空间挤出点的投影和深度 |
| 脸颊少了泛红 | 漏掉照明后的视角 SDF 染色,整数标志也容易误读 | 补回 FaceLightingTint,主体与 SSS 共用 |
还有一个关键实验:只在诊断里替换历史颜色 RGB,腿部时域误差明显下降。这让我继续查历史如何累积,但实验没有接进正常渲染。关掉裁剪、固定混合比例、增加累计次数,也都试过;没改善的方案没有留下来。

工程最后收拢成身体、脸、眼睛、头发 4 个 Renderer、14 个材质槽,Shader 按输入、表面、光照和 Pass 拆开。GUI 暴露实际需要调的参数,捕获常量留在高级区;重导入丢失的非序列化数组在绘制前检查补回,同时保留已经保存的美术参数。
16结果、验证范围和还没解决的部分
最终归档全身前景 RGB MAE 为 3.500,P95 为 15。数值使用 0–255 RGB 编码值,范围是同机位前景,不是“还原度百分比”。
| 区域 | 归档 RGB MAE |
|---|---|
| 全身前景 | 3.500 |
| 头发和脸部 | 4.290 |
| 衣服和手 | 3.766 |
| 腿部 | 1.273 |
| 鞋子 | 6.041 |
描边、嘴部、泛红和 MSAA 的局部指标来自不同测试范围,不能直接相加成整体改善率。Stable 的腿部 ROI 和这里最终全腿区域也不是同一个框,不能拿 0.943 与 1.273 直接判断退化。
实现阶段保留了静态对照、逐阶段 GPU 数值核对、投影组合测试,以及 88 个实际连续帧的资源切换与相机运动回归。最终展示图是 28 次相机渲染后的静态复查,不等于又跑了一轮动态回归。作品页发布只核对文档与现有源码,没有为了页面重新运行一套 Unity/GPU 测试。
现在还只有捕获姿态的静态模型和固定阴影几何。PBR 的头饰、衣服响应,眼睛高光、发丝和鞋子细节还需要继续查。UE 游戏角色移植是下一步独立实现,仍处于计划阶段;这里展示的完成部分是 Unity 还原。
17实现位置与阅读顺序
下面是当前工程内的实现位置。公式使用了更容易阅读的符号,带分支的地方在正文说明;它们不是对任意游戏角色都成立的一组通用常数。
| 文件 | 主要函数 / 职责 |
|---|---|
| Shaders/Includes/AnantaCharacterSurface.hlsl | MaterialRow、Recolor、DecodeNormal、RampColor、FaceShadow、FaceLightingTint、EyeAlbedo |
| Shaders/Includes/AnantaCharacterLighting.hlsl | BodySpecular、SpecularEnergy、Captured 环境与附加光、EyeHighlights、HairBandTint |
| Shaders/Includes/AnantaCharacterPasses.hlsl | ShadeDiffuse、前向颜色、分类、头发透明、散射输入 |
| Shaders/Includes/AnantaOutlineGeometry.hlsl | 辅助平滑法线、捕获宽度和投影尺度外扩、深度偏置 |
| Shaders/Includes/AnantaOutlineShading.hlsl | 描边换色、HSV、环境与主光朝向响应 |
| Shaders/AnantaMsaaSurface.shader | 类别 / 覆盖 / 深度分开 resolve,逐样本 SSS 合成 |
| Shaders/AnantaShadowMultiply.shader | 模板约束、类别过滤、后置阴影乘法 |
| Shaders/AnantaLocalExposure.compute | 分块直方图、对数亮度、局部增益 |
| Shaders/AnantaTemporal.compute | 覆盖、深度重投影、拒绝、四帧亮度历史、细节锁定 |
| Shaders/AnantaPost.shader | SSS、最近深度运动准备、时域核、裁剪、RCAS、Bloom、LUT |
| Rendering/AnantaOutlineFeature.cs | 描边与刘海绘制顺序 |
| Rendering/AnantaPostProcessFeature.cs | 资源格式、相机历史、处理顺序、六级 Bloom 与输出 |
这些路径都相对于 Assets/AnantaPreview。读代码时,我会先看 ReadSurface 和 ShadeDiffuse,确认当前材质类型,再进入专用高光和后处理,最后回到 Feature 检查它实际在何时、向哪张缓冲写入。
哪些数字还只确认到了实现作用
环境 mip 公式里的 6 是粗糙度为 1 时的目标层级,1.2 决定每减半粗糙度下降多少 LOD;例如 r 为 0.5 时是 4.8,r 为 0.25 时是 3.6。它们需要和捕获的预过滤 Cubemap 匹配。SH 后的 18 则让亮度到 1/18 时饱和度门控到达 1。这两组规则、脸部曲线、泛红、眼睛色散偏移、头发手工光向混合和时域反馈强度,当前都没有原始设计或调参资料。
我保留这些值是为了复现这份捕获的计算,不是把它们推荐成另一个项目的默认参数。接到 UE 的游戏角色时,投影与曝光要从当前场景取得,材质编码和 LUT 要与实际资源配套;风格参数则要重新看转头、运动和不同灯光下的结果。