This commit is contained in:
2025-08-02 12:09:34 +08:00
commit e70b01cdca
2785 changed files with 575579 additions and 0 deletions

View File

@@ -0,0 +1,232 @@
---
title: 二维SDF面部阴影方案
date: 2023-12-08 23:43:36
excerpt:
tags:
rating: ⭐
---
原文为https://zhuanlan.zhihu.com/p/670837192
# 效果
https://www.bilibili.com/video/BV1Ac411D7gj/?t=2&spm_id_from=333.1007.seo_video.first&vd_source
## 一、前言
目前实时渲染领域卡通渲染脸部光照主要有两种实现方法:
1. 修改法线
2. 使用SDF贴图
关于脸部卡通渲染方案的介绍可以看参考下面这篇文章:
https://zhuanlan.zhihu.com/p/411188212
下面说一下我对这两种方案的理解。
### 1.1修改法线的方案
![](https://pic3.zhimg.com/80/v2-d936db9c0dca2c5b656e8a83601519d2_720w.webp)
首先如果只是修改模型顶点的法线的话模型顶点数就那么多比如上面的模型已经做得很细致了但顶点数也就5k传递球形法线做比较柔和的光照效果还比较合适想表现复杂的结构是比较困难的。
当然也有修改模型拓扑等方法,但我觉得这些方法都不是特别优雅。
我个人觉得使用法线贴图会更有潜力一些自己私底下也有在研究把SDF烘培成法线贴图的方法目前的结果我自己并不是很满意如果后续有一些有意思的结果我会再出一篇文章跟大家分享。
![](https://pic2.zhimg.com/80/v2-d6d552383739dbf43c57ff074856d8d1_720w.webp)
通过SDF生成的法线贴图
![动图封面](https://pic4.zhimg.com/v2-9c6f061b2ef97919653327e2885fd93f_b.jpg)
使用SDF法线贴图shading的效果
> 为了方便烘培成世界空间的法线,烘培到切线空间会更合适
### 1.2 使用SDF的方案
SDF的方案是目前市面上使用得最多的了新出的二游基本都会用这种方式。
在实现上SDF的方案并不困难但是由于我自己使用unreal引擎比较多自己想通过改管线的方法来实现的话很难改得很优雅所以我以后还会继续研究法线贴图方案。
在表现上目前SDF的方案最明显的问题是没有Z轴上的变化这也是这篇文章主要想解决的问题。本文的实现思路其实很简单目前的SDF都只画了一组水平轴的光照那么我们把其他所有轴都画出来就行了。
![](https://pic4.zhimg.com/80/v2-b54d51aa0fd056e7f7ae1d1ac52930c7_720w.webp)
使用全角度的SDF图集代替水平轴的SDF
两三年前看到原神的SDF方案时就想到了这个方法相信平时比较关注卡通渲染的各位都想到过这个方法但是几年过去了没有看到任何一篇文章实现了这个方案看来大伙也都觉得把所有方向的光照都画出来工作量太大了不敢尝试所以只能我自己尝试实现给大伙看了。
## 二、实现流程
### 2.1 流程概况
三句话简单概括一下:
1.画好各个角度的光照如下我一共画了65张稍微偷懒点少画写应该也没问题。
![](https://pic1.zhimg.com/80/v2-54cfc4f5653757926938a5610988bc14_720w.webp)
在blender中绘制光照图
2.把画完的光照图转成SDF然后拼成一张图集
![](https://pic3.zhimg.com/80/v2-37af28982eff159cb0453c4044783eae_720w.webp)
生成的SDF和SDF图集
3.通过光源角度<E8A792><EFBFBD>计算合适的uv采样4次进行插值。
![](https://pic4.zhimg.com/v2-61cfc45befde6a08947b5c1a7220ddf7_r.jpg)
采样四次SDF图集计算光照结果
### 2.2 光照图绘制
绘制光照没有目前没想到什么很好的生成方式纯靠手绘我没啥美术能力画这65张图花了我一整个星期的空闲时间如果有美术大佬愿意画的这东西的话可能效果会更好一些。这一堆的模型有点制作表情blend shape的感觉了
光照图我画了9行9列第一行和最后一行只有一张所有一共9x7+2=65张
光照图的每一行代表平行光从下面的半球从上到下的9种不同的经度投射到脸上每一列代表9不同的纬度第一行和最后一行平行光从正上和正下投射过来所以只有一张。
![](https://pic2.zhimg.com/80/v2-2644b18113de971f03204050cdd2db69_720w.webp)
绘制的光照图相当于光照从左半球上各点照射过来的结果
![](https://pic1.zhimg.com/80/v2-ff89d7d1c8951d5766b4a4451c419818_720w.webp)
有一点需要注意的可以看到我绘制光照图的时候涂出了uv的边界这样生成的SDF在uv边界效果不会出问题不想让SDF超出uv边界的话等生成了SDF之后再把超出边界的部分去掉就行了
![](https://pic4.zhimg.com/80/v2-99b39fc12e397f46d180b44f1f4d006b_720w.webp)
绘制光照图时建议涂出uv边界
下面这个是反面例子:
![动图封面](https://pic1.zhimg.com/v2-32595804b0860eb38b9c1f0feac33100_b.jpg)
脸部上方uv边界处SDF插值效果不好
下面放点动画里的光影变化作为参考:
![](https://pic3.zhimg.com/80/v2-c92d191c8c8a197f608c47c4dbd00b72_720w.webp)
奇蛋物语
![](https://pic3.zhimg.com/80/v2-92f10c1604c942dfbe0fc52a2d3808a6_720w.webp)
奇蛋物语、偶像大师、Mygo
![](https://pic1.zhimg.com/80/v2-caa00e5a2cb76b2a90e0c28ad7439f38_720w.webp)
轻音少女
### 2.3 SDF图集制作
![](https://pic1.zhimg.com/80/v2-2bb15862e70a9dec8178c7a14c909030_720w.webp)
使用8ssedt算法生成SDF
首先我们需要把黑白的光照图转为SDF我使用的是8ssedt算法生成的关于8ssedt算法的详细可以看下面的这两篇文章
https://zhuanlan.zhihu.com/p/337944099
https://blog.csdn.net/qq_41835314/article/details/128548073
接下来把所有的光照图拼成一张图集:
![](https://pic3.zhimg.com/80/v2-e484cd7511644a1a412c7afa7a0e33ce_720w.webp)
SDF图集
这里解释一下我为什么不生成这种SDF这种SDF在下文都使用“插值后的SDF”特指
![](https://pic2.zhimg.com/80/v2-3236e665764311dfd1744482f4961455_720w.webp)
“插值后的SDF”
使用这种插值后的SDF其实是有一个条件的后面的光照一定要覆盖前面的光照。
比如下面这套SDF图中光照图的范围从左到右一定是逐渐增大的
![](https://pic4.zhimg.com/80/v2-d36721263248ca40e181bde267020ce7_720w.webp)
https://zhuanlan.zhihu.com/p/411188212
这样导致的结果就是当光从前方照过来的时候,如下图左边整个脸必须是完全亮的,不可能出现右边这种脸的后方没被照亮的效果。
![](https://pic1.zhimg.com/80/v2-7d4ee004d2744fee72753611a0e3f888_720w.webp)
“使用插值后的SDF的缺点”
再举一个例子下面3张图之间没有包含关系所有转成SDF再插值之后的效果是右边这样是不是看起来就不对劲。
![](https://pic4.zhimg.com/80/v2-c57042f04721311f5131a31f2c1c8f03_720w.webp)
使用没有包含关系的图像生成“插值后的SDF”
看看效果,确实不对劲:
![动图封面](https://pic1.zhimg.com/v2-c5f040339d6c510f8ed3d6d8d752d308_b.jpg)
“插值后的SDF”错误的效果
导致这种错误的原因在于生成“插值后的SDF”的算法
- 算法在255次循环中计算出了不同光源角度对应的光照结果这一步是没问题的
- 但是为了能够将255种不同的结果保存在一张贴图里算法里的做法是将所有结果累加起来这一步破坏了插值信息
```cpp
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++) {
for (int i = 0; i < 255; i++) {
if (nextTexIndex >= int(grayImages.size())) {
break;
}
float weight = lerpStep / levelStep;
// 这里采样了相邻的两张SDF
int curPixel = grayImages[curTexIndex].at<uchar>(y, x);
int nextPixel = grayImages[nextTexIndex].at<uchar>(y, x);
int lerpPixel = curPixel * weight + nextPixel * (1 - weight);
// 在这里计算出了不同光源角度对应光照结果
result += lerpPixel > 127 ? 0 : 1;
// 结果进行累加
lerpStep++;
if (lerpStep >= levelStep)
{
lerpStep = 0;
curTexIndex++;
nextTexIndex++;
}
}
lerpedSDF.at<Vec3b>(y, x)[0] = int(result);
lerpedSDF.at<Vec3b>(y, x)[1] = int(result);
lerpedSDF.at<Vec3b>(y, x)[2] = int(result);
result = 0;
curTexIndex = 0;
nextTexIndex = 1;
lerpStep = 0;
}
}
```
如果只做水平轴的SDF其实“后面的光照范围必须大于前面的光照”的这个条件其实无伤大雅而且用一张贴图代替了9张SDF性价比很高
但是我们做全角度的SDF光照本身就需要做图集直接用SDF就好了而且不会受上面条件的影响效果会更好。
### 2.4 工具
以上转SDF和拼图集的操作我写了一个小工具你们直接用我的工具来做就行了不用再浪费时间去重复造轮子了。
![](https://pic4.zhimg.com/80/v2-14be2af3bbfaf948ea57acfb447b8beb_720w.webp)
生成SDF和图集的小工具
工具我和工程放在一起解压SDFTool.zip然后双击main.exe就能打开
![](https://pic3.zhimg.com/80/v2-b6b19fafee9fe566bf660bcde9501e0a_720w.webp)
美术的同学直接使用打包好的工具就行了
工具具体的使用文档可以在我的github上查看源码也在上面工具写得不是很鲁棒如果出了什么问题的话有能力的朋友直接改源码吧。
![](https://pic4.zhimg.com/80/v2-ee795bab4a98c57329746d53446a6e0b_720w.webp)
### 2.5 计算光源角度采样SDF图集
下面很多操作都跟正常的SDF脸部光照一样我就不写得太详细了具体直接打开我的工程看就行了。
1.在蓝图里把角色脸部向前的向量和先左的向量传到角色的材质里
![](https://pic1.zhimg.com/v2-3dc9c43350f9f137a548f7749f8f58f0_r.jpg)
就是把下图下面这两个箭头的方向传到材质里:
![](https://pic4.zhimg.com/80/v2-ac49744b912008682c82bdb7773ae27f_720w.webp)
- 叉乘朝左和朝前的向量,得到朝上的向量
- 将光源方向与脸部朝左向量点乘然后step一下用于后面判断光源是从脸部左边还是右边照过来的
- 将光源方向与脸部朝前、朝上的向量点乘,得到光源的水平夹角的$cos\theta$和垂直夹角的$cos\phi$
![](https://pic4.zhimg.com/v2-a01e1bdf17045438ed416a4fc3ab03ab_r.jpg)
- $cos\theta$和$cos\phi$它们是余弦值不是线性的所以我喜欢使用arccos把它们变成线性的角度
![](https://pic4.zhimg.com/v2-471260088c59a0af2d31b477d1fc0adb_r.jpg)
- 接下来采样的部分看起来比较杂乱
![](https://pic1.zhimg.com/v2-056d729540745190a5e508a6fef58f1c_r.jpg)
我们稍微拆开来看:
- 这里是定义了两个常数应该图集是9x9的所有定义了Row=1Row-1的意思是Row-1=8
![](https://pic1.zhimg.com/80/v2-a9ae4efbaea60467e116196313afde74_720w.webp)
- 当光源在右边时镜像翻转uv
- 然后uv除以行数9就得到了贴图左上角的第一张SDF的uv
![](https://pic4.zhimg.com/v2-015fd1bbeb9f75e78cb8e52ff1c5c6a7_r.jpg)
- 接下来要计算离当前光源角度最近的4张SDF的uv比如当水平角度<E8A792>等于100度垂直角度<E8A792>等于120度时会采样下面画的四个点的SDF然后按照权重来插值跟双线性插值很像。
![](https://pic3.zhimg.com/80/v2-5c692fe323ea9c974ca6bff0a8ae025a_720w.webp)
采样当前光源角度最近的四张SDF
- 这里计算离光源最近的行数和列数
![](https://pic3.zhimg.com/80/v2-c93bf64082d6d6ded87726824aaa809e_720w.webp)
- 接下来计算出uv采样贴图并插值出光照结果最后上色
![](https://pic4.zhimg.com/v2-76e883b08f60d8a92f8b8de0bb04ad0b_r.jpg)
![](https://pic1.zhimg.com/80/v2-3de2833216543354784df04eb139cc18_720w.webp)

View File

@@ -0,0 +1,195 @@
---
title: Untitled
date: 2024-12-08 12:18:54
excerpt:
tags:
rating: ⭐
---
# 前言
阴影渲染笔记:[[Shadow]]
实现功能:
1. [ ] 控制深度偏移
2. [ ] CustomDepth制作头发阴影偏移效果哦 https://zhuanlan.zhihu.com/p/689578355
3. [ ] ContactShadow接触阴影实现衣服细节的DetailShadow
4. [ ] 半程阴影
# 半程阴影
由晨风&Neverwind提出
- 【[UFSH2024]用虚幻引擎5为《幻塔》定制高品质动画流程风格化渲染管线 | 晨风 Neverwind 完美世界游戏】 【精准空降到 07:27】 https://www.bilibili.com/video/BV1rW2LYvEox/?share_source=copy_web&vd_source=fe8142e8e12816535feaeabd6f6cdc8e&t=447
**阴影Setup阶段**:
```c++
if 启用半程阴影:
{
额外进行一次CreatePerObjectProjectedShadow()
{
处理阴影光照Matrix //猜测:
//在ProjectedShadowInfo->SetupPerObjectProjection()中调整FProjectedShadowInfo.TranslatedWorldToView。
//在LightSceneInfo->Proxy->GetPerObjectProjectedShadowInitializer(Bounds, ShadowInitializer)之后修改WorldTolight。
向阴影信息写入IsHalfViewShadowFlag//猜测在FProjectedShadowInfo中添加判断Flag并且写入。
用新的光源方向画Atlas //猜测给带有对应Flog的DirectionLigh多创建一个对应的Atlas
}
}
```
截图代码(半程阴影修改LightDirection逻辑)
```c++
if(PrimitiveSceneinfo->Proxy->IsToonDisableSelfShadow())
{
...
for (int32 ViewIndex = 0; ViewIndex < Views.Num(); ViewIndex++)
{
const FViewInfo& View = Views[ViewIndex];
const FMatrix& ViewMatrix = View.ShadowViewMatrices.GetViewMatrix()
FVector LightDirection = Lightsceneinfo->Proxy->GetDirection();
const FVector CameraDirection = ViewMatrix.GetColumn(2).GetSafeNormal()
float LightViewBlendFactor = PrimitiveSceneinfo->Proxy->GetToonHalfVienShadowFactor();
Fvector HalfViewLightDir = (LightDirection + ( 1 - LightViewBlendFactor) + CameraDirection * LightViewBlendFactor).GetSafeNormal();
FMatrix FinalCombineMatrix = FInverseRotationMatrix(HalfViewLightDir.Rotation())
ShadowInitializer.WorldToLight = FinalCombineMatrix;
}
...
}
```
相关代码位于`FDirectionalLightSceneProxy::GetPerObjectProjectedShadowInitializer()`在在FSceneRenderer::CreatePerObjectProjectedShadow()被调用。
```c++
virtual bool GetPerObjectProjectedShadowInitializer(const FBoxSphereBounds& SubjectBounds,FPerObjectProjectedShadowInitializer& OutInitializer) const override
{
OutInitializer.PreShadowTranslation = -SubjectBounds.Origin;
OutInitializer.WorldToLight = FInverseRotationMatrix(FVector(WorldToLight.M[0][0],WorldToLight.M[1][0],WorldToLight.M[2][0]).GetSafeNormal().Rotation());
OutInitializer.Scales = FVector2D(1.0f / SubjectBounds.SphereRadius,1.0f / SubjectBounds.SphereRadius);
OutInitializer.SubjectBounds = FBoxSphereBounds(FVector::ZeroVector,SubjectBounds.BoxExtent,SubjectBounds.SphereRadius);
OutInitializer.WAxis = FVector4(0,0,0,1);
OutInitializer.MinLightW = -UE_OLD_HALF_WORLD_MAX;
// Reduce casting distance on a directional light
// This is necessary to improve floating point precision in several places, especially when deriving frustum verts from InvReceiverMatrix
// This takes the object size into account to ensure that large objects get an extended distance
OutInitializer.MaxDistanceToCastInLightW = FMath::Clamp(SubjectBounds.SphereRadius * CVarPerObjectCastDistanceRadiusScale.GetValueOnRenderThread(), CVarPerObjectCastDistanceMin.GetValueOnRenderThread(), (float)WORLD_MAX);
return true;
}
```
PS.很有可能需要创建2个Atlas。Atlas的创建位于***FSceneRenderer::AllocateCachedShadowDepthTargets()***。数据存储在***SortedShadowsForShadowDepthPass.ShadowMapAtlases***中。大致由FSceneRenderer::FinishInitDynamicShadows()调用。
**阴影Projection阶段**:
//此阶段需要屏蔽角色投射到自己的非半程阴影
//和角色投射到场景中会跟随视角移动的阴影
```c++
if(Toon材质,且没有半程阴影Flag的阴影
&&非Toon材质但有半程阴影Flag的阴影)
{
屏蔽此阴影
}
```
PS.很有可能在FProjectedShadowInfo::RenderProjection()阶段进行判断以此保证合成正确的**ScreenShadowMask**。
# 实现方法
```c++
const FMaterialRenderProxy* MaterialRenderProxy = MeshBatch.MaterialRenderProxy;
bool bEnableToonMeshDrawOutline = MaterialRenderProxy->GetToonOutlineDataAssetRT()->Settings.bEnableToonMeshDrawOutline;
```
FProjectedShadowInfo->Scene
FPrimitiveSceneProxy
## 深度偏移
### ~~方法一~~
1. FProjectedShadowInfo添加变量。
FSceneRenderer::RenderShadowDepthMaps() => RenderShadowDepthMapAtlases() => ProjectedShadowInfo->RenderDepth()
已放弃FProjectedShadowInfo无法判断MeshSection。
### 方法二(最终实现方法)
在材质中使用ShadowPassSwitch再对ViewSpace的Z轴方向使用DirectionalLightVector比较可以只对方向光进行偏移进行WPO偏移实现。
其优点就是可以用贴图来控制偏移过渡。
## DirectionOffsetToViewShadow
### 最终实现方法
1. 在**FProjectionShadowInfo**中添加**bDirectionOffsetToViewShadow**标记以及对应的判断函数IsDirectionOffsetToViewShadow()来判断是否是DirectionOffsetToViewShadow。
1. 在**FSceneRenderer::CreatePerObjectProjectedShadow()** 中再次调用SetupPerObjectProjection()逻辑创建**DirectionOffsetToViewShadow**时将FProjectedShadowInfo的***bDirectionOffsetToViewShadow设置成true***。
2. 在PrimitiveSceneProxy.h 中添加DirectionOffsetToViewShadowAlpha变量作为偏移Alpha同事添加函数UseDirectionOffsetToViewShadow()来判断是否开启这个功能。
1. 在**FSceneRenderer::CreatePerObjectProjectedShadow()** 中取得PrimitiveSceneProxy中的DirectionOffsetToViewShadowAlpha最后计算向量来设置***ShadowInitializer.WorldToLight***。大致为[[#DirectionOffsetToViewShadow Direction Code]]
3. 在ToonDataAsset中添加RecivedViewOffsetShadow作为是否接收DirectionOffsetToViewShadow的依据。
1. 将数据渲染到ToonDataAsset Texture中。
2. 最终在ShadowProjectionPixelShader.usf获取并且计算。代码如下
```c++
//BlueRose Modify
#if SHADING_PATH_DEFERRED && !FORWARD_SHADING && !SUBPIXEL_SHADOW && !STRATA_ENABLED /*&& !USE_TRANSMISSION*/
FGBufferData GBufferData_Toon = GetGBufferData(ScreenUV);
const uint ToonDataAssetID = GetToonDataAssetIDFromGBuffer(GBufferData_Toon);
float RecivedViewOffsetShadow = GetRecivedViewOffsetShadow(ToonDataAssetID);
if (IsDirectionOffsetToViewShadow > 0)//ViewShadow ProjectionShadowInfo
{
/*if (RecivedViewOffsetShadow > 0)//ViewOffsetShadow
{
PerObjectDistanceFadeFraction *= 1.0;
}*/
if (RecivedViewOffsetShadow == 0)
{
PerObjectDistanceFadeFraction *= 0.0;
}
}else//Normal ProjectionShadowInfo
{
if (RecivedViewOffsetShadow > 0)
{
PerObjectDistanceFadeFraction *= 0.0;
}
}
#endif
//BlueRose Modify End
float FadedShadow = lerp(1.0f, Shadow, ShadowFadeFraction * PerObjectDistanceFadeFraction);
#if FORWARD_SHADING || SHADING_PATH_MOBILE
float LightInfluenceMask = GetLightInfluenceMask(TranslateWorldPosition);
// Constrain shadowing from this light to pixels inside the light's influence, since other non-overlapping lights are packed into the same channel
FadedShadow = lerp(1, FadedShadow, LightInfluenceMask);
// Write into all channels, the write mask will constrain to the correct one
OutColor = EncodeLightAttenuation(FadedShadow);
#else
float FadedSSSShadow = lerp(1.0f, SSSTransmission, ShadowFadeFraction * PerObjectDistanceFadeFraction);
// the channel assignment is documented in ShadowRendering.cpp (look for Light Attenuation channel assignment)
OutColor = EncodeLightAttenuation(half4(FadedShadow, FadedSSSShadow, FadedShadow, FadedSSSShadow));
#endif
```
#### DirectionOffsetToViewShadow Direction Code
```c++
...
bool bToonDirectionOffsetToViewShadow = ToonDirectionOffsetToViewShadowCVar->GetValueOnRenderThread();
float LightViewBlendFactor = PrimitiveSceneInfo->Proxy->DirectionOffsetToViewShadowAlpha;
if (bToonDirectionOffsetToViewShadow && LightSceneInfo->Proxy->GetLightType() == LightType_Directional && LightViewBlendFactor > 0.0f)
{
//计算半程向量针对每个View都会生成一个FProjectedShadowInfo之后在RenderShadowProjection()中判断?
for (int32 ViewIndex = 0; ViewIndex < Views.Num(); ViewIndex++)
{
const FViewInfo& View = Views[ViewIndex];
const FMatrix& ViewMatrix = View.ShadowViewMatrices.GetViewMatrix();
FVector LightDirection = LightSceneInfo->Proxy->GetDirection();
const FVector CameraDirection = ViewMatrix.GetColumn(2).GetSafeNormal();
FVector HalfViewLightDir = (LightDirection * ( 1 - LightViewBlendFactor) + CameraDirection * LightViewBlendFactor).GetSafeNormal();
FMatrix FinalCombineMatrix = FInverseRotationMatrix(HalfViewLightDir.Rotation());
ShadowInitializer.WorldToLight = FinalCombineMatrix;
...
}
}
```
# ~~NoSelfShadow~~此方法是错误的
https://zhuanlan.zhihu.com/p/10073818586?utm_psn=1877051516055076866
UPrimitiveComponent::bSelfShadowOnly
=>
FPrimitiveSceneProxy::bSelfShadowOnly => **CastsSelfShadowOnly()**
=>
FProjectedShadowInfo::bSelfShadowOnly
- FProjectedShadowInfo::SetupMeshDrawCommandsForProjectionStenciling()设置Stencil为1。
- 在`FProjectedShadowInfo::GatherDynamicMeshElements()`被调用FSceneRenderer::GatherShadowDynamicMeshElements()。
- FProjectedShadowInfo::SetupProjectionStencilMask设置Stencil为7 pre-shadow/per-object static shadow
- 在`FProjectedShadowInfo::RenderProjectionInternal()`被调用。
FProjectedShadowInfo::RenderProjectionInternal()

View File

@@ -0,0 +1,204 @@
---
title: 卡通面部阴影控制
date: 2023-03-08 10:18:36
excerpt:
tags:
rating: ⭐
---
# 相关资料
- [虚幻5渲染编程(风格化渲染篇) 第七卷: Toon shadow control](https://zhuanlan.zhihu.com/p/519728086)
- [二次元角色卡通渲染—面部篇](https://zhuanlan.zhihu.com/p/411188212)
- [掰法线的方法 SP Normal Convert Shader](https://note.com/sfna32121/n/n8d46090005d1)
- 绘制SDF贴图方法
- [卡通脸部阴影贴图生成 渲染原理](https://zhuanlan.zhihu.com/p/389668800)
- [【教程】使用csp等高线填充工具制作三渲二面部阴影贴图](https://www.bilibili.com/video/BV16y4y1x7J1/)
- SDF Shadow Shader
- [神作面部阴影渲染还原](https://zhuanlan.zhihu.com/p/279334552)
# SDF面部阴影
## Signed Distance Fields
- 生成距离场算法: http://www.codersnotes.com/notes/signed-distance-fields/
>这实质上就是找目标点及左上方四个点中SDF最小的值。PASS0就是按照从上到下从左到右的顺序遍历整个图像遍历完成之后对于所有物体外的点如果距离它最近的物体是在它的左上方那么它的SDF值就已确定。类似的PASS1就是按照从下到上从右到左的顺序依次比较右下方的四个点遍历完成之后对于所有物体外的点如果距离它最近的物体是在它的右下方那么它的SDF也已经确定了。两个PASS结合那么整个图像的SDF就都计算出来了。其实这里称SDF并不准确因为只算了物体外到物体边界的距离是正值并没有signed一说只有做完下一步计算物体内到物体外的距离两个距离相减才是SDF
>第二个grid的GenerateSDF就很好理解了就是**计算物体内部到外部的距离**。因为一个点要么在物体内要么在物体外所以两次的SDF值要么全为零在边界上要么一个为0一个为距离值。用grid1(pixel).sdf - grid2(pixel).sdf就能得到完整的SDF。
1. 读取传入的贴图并且构建2个RT一个黑inside Zero一个白Outside infinitely当亮度大于0.5128时则反转。
2. 分别对这2个RT执行 GenerateSDF()。
3. 将2个RT记录的距离值相减获得最终的SDF。
链接中的代码:
```c++
struct Point
{
int dx, dy;
int DistSq() const { return dx*dx + dy*dy; }
};
struct Grid
{
Point grid[HEIGHT][WIDTH];
};
```
```c++
void Compare( Grid &g, Point &p, int x, int y, int offsetx, int offsety )
{
Point other = Get( g, x+offsetx, y+offsety );
other.dx += offsetx;
other.dy += offsety;
if (other.DistSq() < p.DistSq())
p = other;
}
//采用分离式计算SDF
//GenerateSDF:计算并且取得当前UV坐标附近(第一次找到)像素最小值(模型使用 0值填充即dx,dy=0,放到RT中。
void GenerateSDF( Grid &g )
{
// Pass 0
for (int y=0;y<HEIGHT;y++)
{
for (int x=0;x<WIDTH;x++)
{
Point p = Get( g, x, y );
Compare( g, p, x, y, -1, 0 );
Compare( g, p, x, y, 0, -1 );
Compare( g, p, x, y, -1, -1 );
Compare( g, p, x, y, 1, -1 );
Put( g, x, y, p );
}
for (int x=WIDTH-1;x>=0;x--)
{
Point p = Get( g, x, y );
Compare( g, p, x, y, 1, 0 );
Put( g, x, y, p );
}
}
// Pass 1
for (int y=HEIGHT-1;y>=0;y--)
{
for (int x=WIDTH-1;x>=0;x--)
{
Point p = Get( g, x, y );
Compare( g, p, x, y, 1, 0 );
Compare( g, p, x, y, 0, 1 );
Compare( g, p, x, y, -1, 1 );
Compare( g, p, x, y, 1, 1 );
Put( g, x, y, p );
}
for (int x=0;x<WIDTH;x++)
{
Point p = Get( g, x, y );
Compare( g, p, x, y, -1, 0 );
Put( g, x, y, p );
}
}
}
```
```c++
int dist1 = (int)( sqrt( (double)Get( grid1, x, y ).DistSq() ) );
int dist2 = (int)( sqrt( (double)Get( grid2, x, y ).DistSq() ) );
int dist = dist1 - dist2;
```
## SDF面部阴影
本质是使用SDF的过度原理(对X张贴图生成SDF贴图使用HalfNoL来查询一个0,180区间的阴影。
### 效果
- SDF面部阴影效果32秒处:https://www.bilibili.com/video/BV1JG4y1r7EP/?spm_id_from=333.337.search-card.all.click&vd_source=d47c0bb42f9c72fd7d74562185cee290
![[SDFShadowEffect.mp4]]
### 绘制方法
- 等高线绘制方法:https://www.bilibili.com/video/BV16y4y1x7J1/?spm_id_from=autoNext&vd_source=d47c0bb42f9c72fd7d74562185cee290
- 注意:等高线曲线需要关闭绘制抗锯齿。
- 使用色阶就可以把各个角度的SDF贴图提取出来。
- Maya烘焙绘制方法应该也可以用UE
1. 给脸部模型一个Lambert材质并且根据X轴角度要求设置好方向光。
2. 使用Arnold的RenderToTexture将贴图输出。
3. 使用PS的色阶功能将颜色二值化并在基础上进行修改。
4. **对8张贴图两两计算SDF值之后进行重映射** 。最终亮度=亮度/8 * index
5. 将结果合并到一起。
### 代码
```c++
float3 UP = float3(0,1,0); // cs脚本里动态修改
float3 Front = float3(0,0,1);
float3 Left = cross(UP, Front);
float3 Right = -cross(UP, Front);
float FrontL = dot(normalize(Front.xz), normalize(L.xz));
float LeftL = dot(normalize(Left.xz), normalize(L.xz));
float RightL = dot(normalize(Right.xz), normalize(L.xz));
float lightAttenuation = (FrontL > 0) * min(
(surfaceData._lightMap.r > LeftL),
1-(1 - surfaceData._lightMap.r < RightL)
);
```
## Multi SDFFaceShadow
- X轴(正面)1°~90°8张 =>
- X轴(背面-1°~-90°):考虑**单个阴影贴图**或者**边缘光计算方式的阴影**。
- Y轴30°、 90°、150°+ 背面阴影
- Y轴(多张贴图)30、50°、70°、90°、110°、130°+ 背面阴影
- -60° 、-40° 、-20° 、0、20°、40°
- 考虑硬编码写死,所以可以使用不同步长的角度。
- 贴图精度可以考虑8bit=>16bit
>Y轴结果插值可以考虑使用扩散Shader
https://www.shadertoy.com/view/tlyfRw
ShaderBits的 UV出血填充贴图方法。
# 距离场+法线混合计算
https://www.youtube.com/watch?v=T2iI9hbNqLI
![[SDF_Normal_Shadow.webp]]![[SDF_Normal_Shadow_Effect.mp4]]
虽然分享中并没有说明具体怎么计算,不过有了这个思路,做出类似的效果并不是难事,以下简单还原的过程。
首先主要代码如下:(图中还有两块区域,即额头偏亮的区域和鼻部底层暗下去的区域,由于并不清楚这两部分的应用场景,下面的计算就忽略这两部分区域,另外也忽略了精度问题)
```c++
float3 shaodwRamp = tex2D(_ShaodwRamp, input.uv);
float3 lightDirH = normalize(float3(L.x, 0, L.z));
float NoL = dot(N, lightDirH);
float triArea = saturate((shaodwRamp.g - 0.5) * 2);
float noseArea = saturate(shaodwRamp.g * 2);
//lightAtten
float lightAtten = dot(lightDirH, forward);
lightAtten = saturate(pow(abs(fmod(lightAtten + _TriAreaOffset, 1)
- 0.5) * _TriAreaLimitUp, _TriAreaPow) * _TriAreaLimitDown - _TriAreaLimitDown + 1);
//uvMask
float filpU = saturate(sign(dot(L, left)));
float cutU = step(0.5, input.uv.x);
float uvMask = lerp(1 - cutU, cutU, filpU);
//NoL
float faceShadow = step(0, NoL);
//三角区域
float triAreaLight = step(lightAtten, triArea) * uvMask;
//鼻部区域
float noseAreaLight = step(lightAtten, 1 - noseArea) * (1 - uvMask);
float noseAreaShadow = (step(lightAtten, noseArea) - 1) * uvMask + 1;
//最后的阴影混合
float Shadow = min(max(max(faceShadow, triAreaLight), noseAreaLight), noseAreaShadow);
```
`SDF取值的思路和上一小节SDF的思路是类似的也是利用lightAtten的结果来取值只不过这张图分别记录了两个区域的值中间值是0.5,三角区域的阈值在(0.5, 1],鼻尖区域的阈值在[0, 0.5)。将这两部分区域重新映射回[0, 1]的区间上就可以使用之前的step操作了然后利用计算的uvMask屏蔽掉不需要的区域最后混合三个部分三角亮区鼻部亮区鼻部暗区的结果就可以了。`
另外由于SDF与NdotL的结果要互相配合所以要对lightAtten的结果重新映射以便控制重新映射的结果如下图的紫色曲线链接内有每个参数的值
![](https://pic1.zhimg.com/80/v2-e09ca89f907402120b95de8b6b844fbc_720w.webp)
# Matcap
又到了万能的Matcap出场了《七つの大罪 光と闇の交戦 : グラクロ》的技术分享中讲述了这种方式,作者在分享中讲到使用这种方式的原因:「根据(动画)演出中非现实的阴影设定,比起物理事实更重视感情的传达」
在大部分情况下卡通画面不需要保证光照的正确性只要画面中的效果是感观舒适就是可行的。所以在非动态光照的下使用Matcap表现角色的光影结构切分是非常简单并且效果还不错的方式。
![](https://pic4.zhimg.com/80/v2-8761284b26369d168ef9ad41e26f287f_720w.webp)