分类目录归档:未分类

Unity中的移动平台RVT实现

谁在用Runtime Virtual Texture这个技术

· UBI在2016年公布了在Far Cry 4中使用的Adaptive Virtual Texture其中就包含Runtime virtual texture.

·UE在4.25版本发布了Runtime Virtual Texture

·鹅厂大佬公布天涯明月刀中使用了Runtime Virtual Texture,表示2层以上的混合就能表现出明显的性能优势

为什么需要Runtime Virtual Texture

  • 你要做任何的大型的景观,就绕不开使用Unity TerrainUE4 Landscape这样的多层混合材质巨型动态Mesh。
  • 从早年的world machine到现在流行的Houdini在地形制作这个部分都在疯狂地丰富一个东西—图层,8层,堪堪够用而已。

8层在十年前你可以认为是8张颜色贴图加两张控制图的混合,十张,好像是有点多,但是现在是PBR的时代,以Unity的Terrain材质为例8层意味着

(1 Albeto + 1 Normal + 1 Detail[AO R + Metal G + Roughness A])* 8 + 2 Control Map = 26次贴图采样

即每个像素,每帧都会有26次贴图采样。这就是为什么Unity官方推荐移动平台不要超过4层的原因。你以为4层就没事了吗?这个标准只能保证中端设备能跑30帧且烫不烫完全不care。

原理

Runtime Virtual Texture做的事情非常简单,把Terrain Mesh分块,把每块的26张贴图合并成2张render target,这样我们渲染的时候不管有多少图层到了渲染的时候都只有2个贴图采样,但是显存必然是有更大的开销的,空间换时间不多废话。

这里必须提出来一个问题,如果我们完全用静态资源,我们的资源到底有多大?以git工程的示例场景为例,一个传统的RPG视角,我们的LOD 0最小切割到8m * 8m,这样的一块在视角内要满足2k分辨率,需要至少1024分辨率的贴图,当然测试下来512也是可以忍的。对于一块512的Terrain,我们的贴图需求是 4096套1024贴图(albeto + normal + detail),这个资源量,再加上mipmap的开销

一、RVT核心原理

  • 虚拟纹理机制

RVT通过逻辑上的无限大纹理动态加载局部所需纹理,类似内存分页管理:

  1. Feedback阶段:低分辨率渲染获取当前视野所需纹理的mipmap等级和位置信息(RGB存储格子坐标与mip等级)
  2. PageTable管理:维护动态更新的页表,记录纹理加载状态和物理存储位置(LRU算法优化Tile复用)
  3. TileTexture烘焙:通过MRT(多渲染目标)实时烘焙Diffuse、法线等数据到物理贴图集
  • 移动端简化方案
  1. 四叉树动态分块:根据相机距离划分地形区块,每块分配相同尺寸贴图,用Texture2DArray存储(索引即数组下标),避免传统RVT的复杂空间管理
  2. 分帧加载:限制每帧细分/合并操作次数(如每帧最大细分4次),避免卡顿

二、关键实现步骤

  • 四叉树动态LOD管理
  1. 数据结构:节点记录坐标(x,z)、尺寸(size)、物理贴图索引(physicTexIndex)及父子节点引用。
  2. 合并与细分合并:当节点远离相机时,回收子节点贴图索引,用父节点低精度贴图覆盖。细分:靠近相机时分裂为4个子节点,分配新索引加载高精度贴图。
  3. 帧更新优化:每帧遍历叶节点队列,按LOD变化决策合并/细分,交换双队列避免GC
  • 物理贴图生成
  1. Blit替代相机渲染:避免传统相机渲染地形Mesh的开销,直接通过Shader一次性采样所有地表纹理(如16层)输出到Texture2DArray
  2. 索引贴图映射:根据地形UV生成索引贴图,存储节点起始坐标、尺寸、贴图数组索引,Shader采样时直接计算实际UV

三、移动端专项优化

1、纹理与内存管理

  • AlphaMap分组:每4种纹理共用一张AlphaMap,减少贴图切换(如12种纹理需3张图)
  • 异步加载:通过AsyncGPUReadback异步回读Feedback数据,延迟一帧使用避免阻塞

2、海洋主题适配

  • 岛屿独立节点:将岛屿抽象为独立四叉树节点,支持随机分布与紧密数据排列
  • 棋盘格烘焙:以512×512米为单位烘焙地形数据,适配航海类大世界

四、工程实践建议

1、开发管线集成

Houdini流程:用Houdini生成地形/道路/桥梁,导出Unity后通过HLOD System预处理(减面+合批),减少70%+ DrawCall

光照方案

  • 近距离:级联阴影(CSM)
  • 远距离:体素化阴影(VSM)+ LightProbe流式加载

2、调式工具

  • 可视化Feedback贴图与PageTable状态,实时监控Tile加载情况
  • 负反馈性能调节系统:动态限制高负载操作(如分帧细分)

其实吧RVT的核心架构一张图就能说清楚

与传统的贴图方案对比优势

优势场景:

  • 超大规模地形(如飞行模拟中的全球地表)
  • 程序化生成世界(无限地形动态烘焙)
  • 高清材质统一管理(避免重复资源)

下面我一步一步从前往后梳理整个过程,以及在Unity的实现技术细节。

格子

我们会根据可视范围把范围内的所有地块划分成格子,格子越小,分到固定分辨率大小的贴图清晰度就越高,当然性能也就越差。UE是固定格子,直接是以地形为单位划分的。但是对于一个超大世界来讲,这个范围是变化的,所以格子也是动态变化的。这里只交待一个概念,后面在各个部分的时候再详细讲。

FeedBack

feedback有点类似于遮挡剔除,会预先烘一个低分辨率的贴图信息,rgb分别表示格子坐标,mipmap等级。格子坐标通过世界坐标在可视区域,以及格子的大小这些参数就能算出来。mipmap等级则是通过偏导求出来的。



feed_v2f VTVertFeedback(feed_attr v)
{
    feed_v2f o;
    UNITY_SETUP_INSTANCE_ID(v);
    
#if defined(UNITY_INSTANCING_ENABLED)
    float2 patchVertex = v.vertex.xy;
    float4 instanceData = UNITY_ACCESS_INSTANCED_PROP(Terrain, _TerrainPatchInstanceData);

    float2 sampleCoords = (patchVertex.xy + instanceData.xy) * instanceData.z; // (xy + float2(xBase,yBase)) * skipScale
    float height = UnpackHeightmap(_TerrainHeightmapTexture.Load(int3(sampleCoords, 0)));

    v.vertex.xz = sampleCoords * _TerrainHeightmapScale.xz;
    v.vertex.y = height * _TerrainHeightmapScale.y;
    
    v.texcoord = sampleCoords * _TerrainHeightmapRecipSize.zw;
#endif
    
    VertexPositionInputs Attributes = GetVertexPositionInputs(v.vertex.xyz);
    
    o.pos = Attributes.positionCS;
    float2 posWS = Attributes.positionWS.xz;
    o.uv = (posWS - _VTRealRect.xy) / _VTRealRect.zw;
    
    return o;
}

float4 VTFragFeedback(feed_v2f i) : SV_Target
{
	float2 page = floor(i.uv * _VTFeedbackParam.x);

	float2 uv = i.uv * _VTFeedbackParam.y;
	float2 dx = ddx(uv);
	float2 dy = ddy(uv);
	int mip = clamp(int(0.5 * log2(max(dot(dx, dx), dot(dy, dy))) + 0.5 + _VTFeedbackParam.w), 0, _VTFeedbackParam.z);

	return float4(page / 255.0, mip / 255.0, 1);
}

在unity urp配置渲染管线,只需要添加一个renderdata,配置好参数

然后在地形里边添加一个feedback的pass

然后在feedback相机上选择这个render

feedback的相机要保证跟场景相机参数一样,可以写个脚本复制参数,然后把这个相机挂在场景相机,transform归零就ok了。这里我把camera的enable去掉了,我自己来调用render,因为这里可以自己控制更新频率。

这里渲染的时候分辨率降低一半,如果觉得还不够可以再降一些,这里为啥不一开始就用低分辨率呢,是因为,先以一个差不多的分辨率精度比较高,再降是取最大的mipmap等级,这样会保证正确性。

float4 GetMaxFeedback(float2 uv, int count)
{
	float4 col = float4(1, 1, 1, 1);
	for (int y = 0; y < count; y++)
	{
		for (int x = 0; x < count; x++)
		{
			float4 col1 = tex2D(_MainTex, uv + float2(_MainTex_TexelSize.x * x, _MainTex_TexelSize.y * y));
			col = lerp(col, col1, step(col1.b, col.b));
		}
	}
	return col;
}

这里之所以对分辨率要求这么高,是因为我们需要把贴图数据从rendertarget里取出到cpu来读取数据。

// 发起异步回读请求
m_ReadbackRequest = AsyncGPUReadback.Request(texture);

这是一个异步请求,我们可能有一个延迟,也就是说一般是这一帧使用的是上一帧的feedback

PageTable

页表是一个mipmap层级结构的表,比如说我们的格子是一个256×256的矩阵,那么mipmap为0的等级就是256×256的cell,mipmap为1的就是128×128的cell,依次类推。cell的上面除了存放mipmap等级,占据的rect,还有page的加载情况。

public class TableNodeCell
{
        public RectInt Rect { get; set; }

        public PagePayload Payload { get; set; }

        public int MipLevel { get; }

        public TableNodeCell(int x, int y, int width, int height,int mip)
        {
            Rect = new RectInt(x, y, width, height);
            MipLevel = mip;
            Payload = new PagePayload();
        }
}


这里要注意的是我们这的pagetable是个可变化的,针对移动是调整后面单独拎出来说。

上面feedback处理完得到一张feedback的贴图,上面各个像素表示需要显示的cell以及mipmap等级。我们可以通过这个信息,去触发加载TileTexture。

/// <summary>
/// 激活页表
/// </summary>
private TableNodeCell ActivatePage(int x, int y, int mip)
{
            if (mip > MaxMipLevel || mip < 0 || x < 0 || y < 0 || x >= TableSize || y >= TableSize)
                return null;
            // 找到当前页表
            var page = m_PageTable[mip].Get(x, y);
            if(page == null)
            {
                return null;
            }
            if(!page.Payload.IsReady)
            {
                LoadPage(x, y, page);

                //向上找到最近的父节点
                while(mip < MaxMipLevel &amp;&amp; !page.Payload.IsReady)
                {
                    mip++;
                    page = m_PageTable[mip].Get(x, y);
                }
            }

            if (page.Payload.IsReady)
            {
                // 激活对应的平铺贴图块
                m_TileTexture.SetActive(page.Payload.TileIndex);
                page.Payload.ActiveFrame = Time.frameCount;
                return page;
            }

            return null;
}
/// <summary>
/// 加载页表
/// </summary>
private void LoadPage(int x, int y, TableNodeCell node)
{
   if (node == null)
     return;

   // 正在加载中,不需要重复请求
   if(node.Payload.LoadRequest != null)
       return;

   // 新建加载请求
   node.Payload.LoadRequest = m_RenderTextureJob.Request(x, y, node.MipLevel);
}

TileTexture

tileTexture会设置tile的size,每个size的分辨率大小,以及padding像素

在这里我们有一个渲染队列,由于同一帧可能请求比较多,我们可以从mipmap等级高到底排序,然后控制每帧渲染数量来进行。

public void Update()
{
            if (m_PendingRequests.Count <= 0)
                return;

            // 优先处理mipmap等级高的请求
            m_PendingRequests.Sort((x, y) => { return x.MipLevel.CompareTo(y.MipLevel); });

            int count = m_Limit;
            while (count > 0 &amp;&amp; m_PendingRequests.Count > 0)
            {
                count--;
                // 将第一个请求从等待队列移到运行队列
                var req = m_PendingRequests[m_PendingRequests.Count - 1];
                m_PendingRequests.RemoveAt(m_PendingRequests.Count - 1);

                // 开始渲染
                StartRenderJob?.Invoke(req);
            }
}

在处理一个具体的tile时,由于我们需要烘焙diffuse,normal,Mask(暂时没处理),我们这里使用的MRT。

然后就转化到要把page上的某个rect,画到TileTexture的某个tile上了,这里还得考虑padding这些,涉及到非常繁琐的tileoffset的计算,这里花了我不少时间。除了渲染地形,以后如果有些需要贴到地形上的贴花也在这个过程中进行。

drawtexture的shader也就是常规的blend混合,这里要注意的是,对于地形超出四层的情况,第一个四层正常渲染,第二个四层以及后面的贴花可以采用Blend One One的形式

PixelOutput frag(v2f_drawTex i) : SV_Target
{
    float4 blend = tex2D(_Blend, i.uv * _BlendTile.xy + _BlendTile.zw);
    
#ifdef TERRAIN_SPLAT_ADDPASS
    clip(blend.x + blend.y + blend.z + blend.w <= 0.005h ? -1.0h : 1.0h);
#endif
    
    float2 transUv = i.uv * _TileOffset1.xy + _TileOffset1.zw;
    float4 Diffuse1 = tex2D(_Diffuse1, transUv);
    float4 Normal1 = tex2D(_Normal1, transUv);
    transUv = i.uv * _TileOffset2.xy + _TileOffset2.zw;
    float4 Diffuse2 = tex2D(_Diffuse2, transUv);
    float4 Normal2 = tex2D(_Normal2, transUv);
    transUv = i.uv * _TileOffset3.xy + _TileOffset3.zw;
    float4 Diffuse3 = tex2D(_Diffuse3, transUv);
    float4 Normal3 = tex2D(_Normal3, transUv);
    transUv = i.uv * _TileOffset4.xy + _TileOffset4.zw;
    float4 Diffuse4 = tex2D(_Diffuse4, transUv);
    float4 Normal4 = tex2D(_Normal4, transUv);

    PixelOutput o;
    o.col0 = blend.r * Diffuse1 + blend.g * Diffuse2 + blend.b * Diffuse3 + blend.a * Diffuse4;
    o.col1 = blend.r * Normal1 + blend.g * Normal2 + blend.b * Normal3 + blend.a * Normal4;
    return o;
}

(这里注意采样纹理一定要自己指定mipmap等级为0,不然显卡会降mipmap等级导致变模糊,这也是我后面发现的,文章和demo都没修改)

画完后就要通知pagetable我加载完毕,并且更新cell上的加载信息。

LookUp
在pagetable每帧更新中,会将TileTexture各个Tile激活下的数据写入lookup贴图,这个lookup的尺寸跟pagetable的cell格子数一样大小,rgb分别表示,cell坐标,mipmap等级。

这里要注意几个地方,第一,Active的page只需取存在feedback上的activepage,而不需取整个TileTexture上的。第二,active的page可能会有重合的地方,这里以mipmap等级低的覆盖等级高的。由于有这两点,我起初是直接在CPU创建一张Texture2D,往里填数据,后来测试发现这样会非常慢,特别是Texture2D的Apply。后来参考UE的代码采用GPUInstance直接画上去,这里由于需要使用mipmap等级低的覆盖高的,可以使用画家算法,按mipmap排序。ue还使用了莫顿码,我这里直接使用InstanceData传入进去整个数据

var mats = new Matrix4x4[drawList.Count];
var pageInfos = new Vector4[drawList.Count];
for(int i=0;i<drawList.Count;i++){
    float size = drawList[i].rect.width / TableSize;
    mats[i] = Matrix4x4.TRS(
                    new Vector3(drawList[i].rect.x / TableSize, drawList[i].rect.y / TableSize), 
                    Quaternion.identity,
                    new Vector3(size, size, size));
    pageInfos[i] = new Vector4(drawList[i].drawPos.x, drawList[i].drawPos.y, drawList[i].mip / 255f,0);
}
Graphics.SetRenderTarget(m_LookupTexture);
var tempCB = new CommandBuffer();
var block = new MaterialPropertyBlock();
block.SetVectorArray("_PageInfo", pageInfos);
block.SetMatrixArray("_ImageMVP", mats);
tempCB.DrawMeshInstanced(mQuad, 0, drawLookupMat,0, mats, mats.Length, block);
Graphics.ExecuteCommandBuffer(tempCB);

shader代码

UNITY_INSTANCING_BUFFER_START(InstanceProp)
UNITY_DEFINE_INSTANCED_PROP(float4, _PageInfo)
UNITY_DEFINE_INSTANCED_PROP(float4x4, _ImageMVP)
UNITY_INSTANCING_BUFFER_END(InstanceProp)
Varyings vert(Attributes IN)
{
	Varyings OUT ;
	UNITY_SETUP_INSTANCE_ID(IN);
	float4x4 mat = UNITY_MATRIX_M;
	mat = UNITY_ACCESS_INSTANCED_PROP(InstanceProp, _ImageMVP);
	float2 pos = saturate(mul(mat, IN.positionOS).xy);
	pos.y = 1 - pos.y;
	OUT.positionHCS = float4(2.0 * pos  - 1,0.5,1);
	OUT.color = UNITY_ACCESS_INSTANCED_PROP(InstanceProp, _PageInfo);
	return OUT;
}

half4 frag(Varyings IN) : SV_Target
{
	return IN.color;
}

这里Unity有几个坑逼的地方,首先unity调用drawMeshInstanced传入的那个矩阵他会修改里边的数据,导致我不能直接使用那个数据,因为我这里不是标准的MVP矩阵,他可能帮我转换了,我这里重新弄了一个矩阵instanceData。还有一点就是如果你在shader里不使用UNITY_MATRIX_M这个变量,unity就认为你这个不是drawInstance。所以我在shader里加了一句无意义的代码float4x4 mat = UNITY_MATRIX_M;

DrawVT

所有数据准备完毕后,现在就比较简单了。直接使用RVT贴图里的diffuse和Normal,然后参与光照计算就OK了。

half4 GetRVTColor(Varyings IN)
{
    float2 uv = (IN.positionWS.xz - _VTRealRect.xy) / _VTRealRect.zw;
    float2 uvInt = uv - frac(uv * _VTPageParam.x) * _VTPageParam.y;
	float4 page = tex2D(_VTLookupTex, uvInt) * 255;
#ifdef _SHOWRVTMIPMAP
    return float4(clamp(1 - page.b * 0.1 , 0, 1), 0, 0, 1);
#endif
	float2 inPageOffset = frac(uv * exp2(_VTPageParam.z - page.b));
    uv = (page.rg * (_VTTileParam.y + _VTTileParam.x * 2) + inPageOffset * _VTTileParam.y + _VTTileParam.x) / _VTTileParam.zw;
    half3 albedo = tex2D(_VTDiffuse, uv);
    half3 normalTS = UnpackNormalScale(tex2D(_VTNormal, uv), 1);
    InputData inputData;
    InitializeInputData(IN, normalTS, inputData);
    half metallic = 0;
    half smoothness = 0.1;
    half occlusion = 1;
    half alpha = 1;
    half4 color = UniversalFragmentPBR(inputData, albedo, metallic, /* specular */ half3(0.0h, 0.0h, 0.0h), smoothness, occlusion, /* emission */ half3(0, 0, 0), alpha);
    SplatmapFinalColor(color, inputData.fogCoord);

    return half4(color.rgb, 1.0h);
}

动态更新
以上基本就能把地形通过rvt画出来了,但是这样有个基本限制,那就是我们移动的时候,如果是超大世界,那么这个pagetable对应的位置就有限。ue的解决办法是每个地形一套rvt,这样会造成,在地形边界可能出现四套rvt,这基本不能接受。天刀采用的是动态更新pagetable,也就是说pagetable对应的cell表示的范围会动态变化。

在changerect的时候,我们需要最大限度地复用TileTexture,不然需要重绘所有的TileTexture,这样会造成比较大的卡顿。这里对于changrect的rect的规范就比较有讲究了,我们需要fixed这个rect,让他尽可能地多复用pagetable。我们这是通过设置更新距离为整个可视距离的四分之一或者八分之一这种,然后通过这个四分之一去fixed可视范围center

动态平面阴影

Shader "fake_shadow"
{
    Properties
    {
        //材质属性面板
        _MainTex ("主贴图",2D) = "white"{}

        _GroundY ("地面Y高度 (外部传入)",float) = 0
        _Shadow_Color("影子颜色",Color) = (1,1,1,1)
        _Shadow_Length("影子长度",float) = 0
        _Shadow_Rotated("影子旋转角度",float) = 0
    }

    SubShader
    {
        Tags
        {
            "Queue" = "Transparent+5" /*"RenderType"="Transparence"  "RenderPipeline" = "UniversalPipeline" *///注意这里很重要,因为影子是要绘制在地面上,所以地面必须应该先绘制,否则blend混合的时候就是和背后的skybox进行混合了
        }

        pass
        {
            Stencil{

                Ref 1
                //Comp取值依次为  0:Disabled  1:Never  2:Less  3:Equal  4:LessEqual  5:Greater  6:NotEqual  7:GreaterEqual  8:Always
                Comp NotEqual //或者改成NotEqual
                //Pass取值依次为  0:Keep  1:Zero  2:Replace  3:IncrementSaturate  4:DecrementSaturate  5:Invert  6:IncrementWrap  7:DecrementWrap
                Pass Replace
            }

            Blend SrcAlpha oneMinusSrcAlpha   

            //因为和地面重叠所以做个偏移
            //也可以不做偏移,将传入的地面高度抬高一点即可
            Offset -2,-2

            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                //这里worldPos一定是float4,因为vert()中实际是手动两次空间变换如果是float3会导致w分量丢失,透视除法会出错
                //如果不参与变换,只是传到frag()中使用的话,比如进行Blinn-Phong光照计算V向量那么float3就够了
                float4 worldPos : TEXCOORD0;
                //做阴影插值和Clip地面以下阴影用
                float cacheWorldY : TEXCOORD1;
                float worldPosY : TEXCOORD2;
};
 //           CBUFFER_START
CBUFFER_START(UnityPerMaterial)
            half _GroundY;
            half4 _Shadow_Color;   
            half _Shadow_Length;     
            half _Shadow_Rotated;
CBUFFER_END
//            CBUFFER_END
            
            v2f vert(appdata v)
            {
                v2f o = (v2f)0;

                //获取世界空间的位置
                o.worldPos = mul(unity_ObjectToWorld,v.vertex);
                //缓存世界空间下的y分量,后续两点作用
                //第一点 : 做插值用做计算xz的偏移量的多少
                //第二点 : 防止在地面以下
                o.cacheWorldY = o.worldPos.y;
    
                o.worldPosY = UnityObjectToWorldNormal(v.vertex).y;

                //设置世界空间下y的值全部都设置为传入的地面高度值
                o.worldPos.y = _GroundY;

                //根据世界空间下模型y值减去传入的地面高度值_GroundY
                //以这个值为传入 lerp(0,_Shadow_Length) 进行线性插值
                //最后获取到模型y值由低到高的插值lerpVal
                //这个max()函数 假设腿部在地面以下则裁切掉腿部阴影,后续使用clip后无需Max
                //half lerpVal = lerp(0,_Shadow_Length,max(0,o.cacheWorldY-_GroundY));
                half lerpVal = lerp(0,_Shadow_Length,o.cacheWorldY-_GroundY);
                

                //常量PI
                //const float PI = 3.14159265;
                //角度转换成弧度
                half radian = _Shadow_Rotated / 180.0 * UNITY_PI;

                //旋转矩阵,对(0,1)向量进行旋转,计算旋转后的向量,该向量就是阴影方向
                //2D旋转矩阵如下
                // [x]        [ cosθ , -sinθ ]
                // [ ]  乘以  
                // [y]        [ sinθ , cosθ  ]
                // x' = xcosθ - ysinθ
                // y' = xsinθ + ycosθ
                half2 ratatedAngle = half2((0*cos(radian)-1*sin(radian)),(0*sin(radian)+1*cos(radian)));
                
                //用以y轴高度为参考计算的插值 lerpVal 去 乘以一个旋转后的方向向量,作为阴影的方向
                //最终得到偏移后的阴影位置
                o.worldPos.xz += lerpVal * ratatedAngle;
                
                //变换到裁剪空间
                o.pos = mul(UNITY_MATRIX_VP,o.worldPos);

                return o;
            }

            fixed4 frag(v2f i) : SV_TARGET
            {
                //剔除低于地面部分的片段
                clip(i.cacheWorldY - _GroundY);
                //用作阴影的Pass直接输出颜色即可
                return _Shadow_Color;
            }

            ENDCG
        }
    }
}
using UnityEngine;

public class SetShadowRotationAndHeight : MonoBehaviour
{

    public int materialIndex = 2; // 设置材质索引

    [SerializeField]
    private Transform rootTransform = null;          //创建模型时设置

    private Renderer rend = null;
    private bool isFindRend = false;
    private float extend = 30f;


    private Transform lightTrans=null;

    void Awake()
    {
        if (GetComponent<MeshFilter>() != null)
        {
            Mesh mesh = GetComponent<MeshFilter>().mesh;
            Bounds bd = mesh.bounds;
            bd.Expand(extend);
            mesh.bounds = bd;
        }
        else if(GetComponent<SkinnedMeshRenderer>() != null)
        {
            //SkinnedMeshRenderer skinnedMesh = GetComponent<SkinnedMeshRenderer>();
            //Bounds bd = skinnedMesh.bounds;
            //bd.Expand(extend);
            //skinnedMesh.bounds = bd;
        }
    }

    public void SetRootTransFrom(Transform root)
    {
        rootTransform = root;

        //GameObject mainLightObj = GameObject.Find("MainLight_Day");
        //if(mainLightObj==null)
        //{
        //    mainLightObj = GameObject.Find("FillLight_Day");
        //}
        //if (mainLightObj != null)
        //    lightTrans = mainLightObj.transform;
    }

    public Renderer GetTransformRenderer()
    {
        if (rend != null)
            return rend;
        if (isFindRend)
            return null;
        rend= GetComponent<Renderer>();
        isFindRend = true;
        return rend;
    }

    bool isDebug = false;

    void Update()
    {
        //if (lightTrans == null)
        //{
        //    if(!isDebug)
        //    {
        //        Debug.Log("找不到光源");
        //        isDebug = true;
        //    }
        //    return;
        //}
            

        if (rootTransform == null)
        {
            rootTransform = transform;
        }
        Renderer tempRend = GetTransformRenderer();

        if (tempRend == null)
            return;

        // 确保指定的材质索引有效
        if (materialIndex < 0 || materialIndex >= tempRend.materials.Length)
        {
            Debug.LogError(transform.name + "Invalid material index!");
            return;
        }

        Material mat = tempRend.materials[materialIndex]; // 获取指定索引的材质

        // 获取父节点的 Y 坐标
        float parentY = rootTransform.position.y + 0.2f;

        // 将父节点的 Y 坐标传递给指定索引的材质的 _GroundY 属性
        mat.SetFloat("_GroundY", parentY);

        // 获取主光源的方向

        if (RenderSettings.sun == null)
            return;

        Vector3 lightDirection = -RenderSettings.sun.transform.forward;
        //Vector3 lightDirection = -lightTrans.forward;

        // 计算主光源的旋转角度的 Y 值(使用 Atan2 函数)
        float shadowRotationY = Mathf.Atan2(lightDirection.x, lightDirection.z) * Mathf.Rad2Deg;

        // 将主光源的旋转角度的 Y 值传递给指定索引的材质的 _Shadow_Rotated 属性
        mat.SetFloat("_Shadow_Rotated", shadowRotationY);
    }
}

捏脸、换装系统总结

最近工作上遇到了与捏脸换装相关的工作内容,由于涉及到了以前未接触过的领域,在这做个工作总结。

捏脸这部分市面上一般有四种解决方案:

1.基于骨骼调整:调整骨骼的Scale、Rotation、Position

2.基于MESH差值:制作两个最大和最小值的Mesh,然后根据差值计算中间的Mesh,运行的时候生产最终的mesh

3.基于材质:简单的来说通过shader与贴图来控制肤色、瞳孔颜色、唇色等。

4.替换Mesh:这个属于是最简单的,但对于美术来说会产生巨量的工作量,一般是用作特殊配件,比如发型、时装、额外的饰品等。

捏脸的系统要保证运行时效率的同时,预览的速度也要做保证,有时候这两者会有一些冲突,可以通过多方案支持、内存换效率之类的方式来进行优化。如果是手游,最好关注下极限情况下的渲染效率和内存消耗,毕竟如果有材质参数的修改是无法合批的,基于骨骼或者mesh的修改的话对于内存也有一定的额外消耗。

基于骨骼调整

其实基于骨骼调整捏脸比较有参考价值的是《Honey Select》中的捏脸系统

对模型资源的规格进行分析, 发现存在大量的morph动画. 也就是说, HS中的的头部骨骼, 全部是用于捏脸的, 表情动画使用MorphTargets(BlendShape)驱动.

对于头部来说这种方式非常便捷,因为HS作为一款端游卖点就是高精度自定义,如果是手机端的话可能可以做相应的取舍。

下面是身体部分

查看其蒙皮信息可以发现, 所有影响顶点的骨骼名字全部带有”_s_”字样, 其父骨骼都是不带”_s_”的同名骨骼. 也就是说, HS的身体骨架中, 父骨骼负责动画, 子骨骼负责蒙皮.

基础的美术工作到这就差不多,但当我们进行调节的时候应该有一个更加平白的表达方式,比如说调节鼻子骨骼的信息,我们的确只需要调节NoseBase的Y值就好了,我们需要做的就是根据滑杆在最大值和最小值之间进行线性插值.

对于”眉毛角度Z轴”的调节, 这时只调节一根骨骼就不对了, 需要左右对称着来. 也就是说, 有一些调节项需要同时调节左右对称的两根骨骼.

对于”眉毛左右位置”, 如果在直线上两个端点之间进行插值, 很容易就跟面部三角形穿插了. 所以这里的插值路径只有最大值和最小值已经满足不了需求了, 而是需要按照曲线进行位置插值, 并且配合旋转插值贴合面部的法线方向. 也就是说, 一个调节项的插值可能是基于曲线(或多个关键帧), 而且可以同时影响骨骼Transform的多个分量.

眼睛的大小调节是最复杂的, 一共影响6根骨骼. 也就是说, 一个调节项是可以对应多根骨骼的.

我们总结一下, 脸型(或体型)调整原理就是:

  • 本质上修改的是骨骼的Local Transform(Translation, Rotation, Scale)
  • 一次只修改Local Transform的某个分量(或多个):Tx/Ty/Tz/Rx/Ry/Rz/Sx/Sy/Sz
  • 使用滑杆在预设的调节范围之间进行插值
  • 插值不一定是线性的, 可能是有多个关键帧
  • 每个调节项可能对应不只一根骨骼

对衣服的资源进行分析可以发现两点值得学习的地方:

  • 每件衣服都配有一个剔除掉被遮住的三角形的裸模, 一方面可以提升绘制性能, 一方面能避免衣服和皮肤两层三角形的穿插
  • 裙摆/披风/长衫等都是共用同样的8条物理骨骼, 算是比较传统的布料模拟做法

基于MESH差值

捏脸主要区域集中在正面,如果美术有能力统一脸部拓扑,使用Mesh差值通过顶点动画实现预设头更好的解决办法。取得新的顶点位置后,要重新计算Mesh法线。

实际脸部形变区域

即使头发使用mesh,头皮依然需要相应颜色的贴图映衬。对于短发寸头,通过对贴图改变发际线的形状也可以取得更多头发的多样性。

捏脸到底使用Mesh差值还是使用骨骼调整,具体还是要看引擎的要求。有的引擎对于Morph Targets优化更好,有的对于骨骼动画优化更好。同时这也取决于你的脸部动画是通过骨骼驱动的还是Morph驱动的。

如果没有性能要求,谁的效果好? 答案肯定是基于Mesh差值的结果。

关于楚留香传的捏脸系统,在额头附近同时调节两个控制器会造成异常的凸起。 这是因为两个控制器同时对相同一部分顶点造成位移而形成的的Double Transformation。可以在美术上normalize两个控制器的影响,或者在程序上限值两个控制器的调整范围。

捏脸是个系统工程,需要美术,程序,设计师多方面的讨论。

说一个不容易发现的细节:有的预设头如果眼睛的距离本身很近,那么控制器是在默认状态就会锁住你的范围,避免你捏出一个眼距过大过小的怪物。

Custom Stylized Lit(STILL WORKING)

该shader主要是为了解决项目中的通用材质的自定义问题,主要是用来实现较为写实的风格,基于URP Lit Shader制作而成,可以控制光的亮度、控制高光与菲涅尔等,并且支持预制笔刷。

1,该shader使用了3种颜色,用阈值与光滑度来remapping表面光照。

2,该shader可以控制Specular的方向与Intensity

3,通过Fresnel调节暗部细节

Gerstner wave

Shader "Unlit/GerstnerWaveStandard"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        [Header(BaseShading)]
        _BaseMap ("Example Texture", 2D) = "white" { }
        [HDR]_BaseColor ("Base Colour", Color) = (0, 0.66, 0.73, 1)
        _WaterFogColor ("Water Fog Colour", Color) = (0, 0.66, 0.73, 1)
        _FogDensity ("Fog Density", range(0, 1)) = 0.1
        _NormalMap ("Normal Map", 2D) = "white" { }
        _NormalScale ("Normal Scale", Range(0, 1)) = 0.1
        _Shininess ("High Light Roughness", Range(0, 0.1)) = 0.01
        [Space(20)]
        [Header(Reflection)]
        _Skybox ("Skybox", Cube) = "white" { }
        [Header(Refractive)]
        _AirRefractiveIndex ("Air Refractive Index", Float) = 1.0
        _WaterRefractiveIndex ("Water Refractive Index", Float) = 1.333
        _FresnelPower ("Fresnel Power", Range(0.1, 50)) = 5
        _RefractionStrength ("Refraction Strength", Range(0, 1)) = 0.1
        
        [Space(20)]
        [Header(SSS)]
        _FrontSubsurfaceDistortion ("Front Subsurface Distortion", Range(0, 1)) = 0.5
        _BackSubsurfaceDistortion ("Back Subsurface Distortion", Range(0, 1)) = 0.5
        _FrontSSSIntensity ("Front SSS Intensity", float) = 0.2
        _HeightCorrection ("SSS Height Correction", float) = 6
        
        [Space(20)]
        [Header(Foam)]
        _FoamIntensity ("Foam Intensity", float) = 0.5
        _FoamNoiseTex ("Foam Noise", 2D) = "white" { }
        
        [Space(20)]
        [Header(Caustic)]
        _CausticIntensity ("Caustic Intensity", float) = 0.5
        _CausticTex ("Caustic Texture", 2D) = "white" { }
        _Caustics_Speed ("Caustics Speed,(x,y)&(z,w)", Vector) = (1, 1, -1, -1)
        
        [Space(20)]
        [Header(Waves)]
        _Speed ("Speed", float) = 0.2
        _Frequency ("Frequency", float) = 2
        _WaveA ("Wave A (dir, steepness, wavelength)", Vector) = (1, 0, 0.5, 10)
        _WaveB ("Wave B", Vector) = (0, 1, 0.25, 20)
        _WaveC ("Wave C", Vector) = (1, 1, 0.15, 10)
        _WaveD ("Wave D", Vector) = (0, 1, 0.25, 20)
        _WaveE ("Wave E", Vector) = (1, 1, 0.15, 10)
        _WaveF ("Wave F", Vector) = (0, 1, 0.25, 20)
        _WaveG ("Wave G", Vector) = (1, 1, 0.15, 10)
        _WaveH ("Wave H", Vector) = (0, 1, 0.25, 20)
        _WaveI ("Wave I", Vector) = (1, 1, 0.15, 10)
        _WaveJ ("Wave J", Vector) = (1, 1, 0.15, 10)
        _WaveK ("Wave K", Vector) = (1, 1, 0.15, 10)
        _WaveL ("Wave L", Vector) = (1, 1, 0.15, 10)
        [Space(20)]
        [Header(Tessellation)]
        _TessellationUniform ("Tessellation Uniform", Range(1, 64)) = 1
        _TessellationEdgeLength ("Tessellation Edge Length", Range(5, 100)) = 50
        [Toggle(_TESSELLATION_EDGE)]_TESSELLATION_EDGE ("TESSELLATION EDGE", float) = 0



        [Space(20)]
        [Header(Others)]
        _shadowAttenuation("shadowAttenuation",float)=0.2
        _CameraOpaqueTexture("_CameraOpaqueTexture",2D)="white"{}
    }
    SubShader
    {
        Tags { "RenderType" = "Transparent"  "RenderQueue" = "Transparent" }
        
        ZWrite Off
        ZTest On
        CGPROGRAM
        #include "Lighting.cginc"
        #include "unityCg.cginc"
        float4 _BaseMap_ST, _FoamNoiseTex_ST, _CausticTex_ST;
        float4 _BaseColor, _WaterFogColor, _Caustics_Speed;
        float4 _NormalMap_ST;
        float4 _WaveA, _WaveB, _WaveC, _WaveD, _WaveE, _WaveF, _WaveG, _WaveH, _WaveI, _WaveJ, _WaveK, _WaveL;
        float _Speed, _Frequency, _NormalScale, _AirRefractiveIndex, _WaterRefractiveIndex, _FresnelPower;
        float _RefractionStrength, _FogDensity, _Shininess, _FrontSubsurfaceDistortion, _BackSubsurfaceDistortion;
        float _FrontSSSIntensity, _HeightCorrection, _FoamIntensity, _CausticIntensity;
        ENDCG

        Pass
        {
            Name "Example"
            Tags { "LightMode" = "ForwardBase" }
            
            ZWrite off
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
           // #pragma multi_compile_fog

            #include "UnityCG.cginc"
            #include "AutoLight.cginc"
            #include "Lighting.cginc"

            struct a2v
            {
                float4 positionOS: POSITION;
                float2 uv: TEXCOORD0;
                float3 normal: NORMAL;
                float4 color: COLOR;
            };

            struct v2f
            {
                float4 positionCS: SV_POSITION;
                float2 uv: TEXCOORD0;
                float3 normalWS: TEXCOORD1;
                float3 positionWS: TEXCOORD2;
                float3 tangentWS: TEXCOORD3;
                float4 scrPos: TEXCOORD4;
                float heightOS: TEXCOORD5;
                float fogFactor: TEXCOORD6;
                float4 color: COLOR;
            };

            samplerCUBE _Skybox;
            float4 _Skybox_ST;
            sampler2D _CameraOpaqueTexture;
            float4 _CameraOpaqueTexture_ST;
            sampler2D _FoamNoiseTex;
            float2 _FoamNoiseTex_ST;
            sampler2D _CausticTex;
            float4 _CausticTex_ST;
            float _Speed, _Frequency, _NormalScale, _AirRefractiveIndex, _WaterRefractiveIndex, _FresnelPower;
            float4 _WaveA, _WaveB, _WaveC, _WaveD, _WaveE, _WaveF, _WaveG, _WaveH, _WaveI, _WaveJ, _WaveK, _WaveL;
            float4 _NormalMap_ST;
            float _RefractionStrength, _FogDensity, _Shininess, _FrontSubsurfaceDistortion, _BackSubsurfaceDistortion;
            float4 _BaseColor, _WaterFogColor, _Caustics_Speed;
            float _shadowAttenuation;
            float _FrontSSSIntensity, _HeightCorrection, _FoamIntensity, _CausticIntensity;


            sampler2D _MainTex;
            float4 _MainTex_ST;
            sampler2D _NormalMap;

            //-------------GenrateGerstnerWave-------------
            float3 GerstnerWave(
                float4 wave, float3 p, inout float3 tangent, inout float3 binormal
            )
            {
                float steepness = wave.z;
                float wavelength = wave.w;
                float k = 2 * UNITY_PI / wavelength;
                float c = sqrt(9.8 / k);
                float2 d = normalize(wave.xy) * _Frequency;
                float f = k * (dot(d, p.xz) - c * _Time.y * _Speed);
                float a = steepness / k;
        
                tangent += float3(
                    - d.x * d.x * (steepness * sin(f)),
                    d.x * (steepness * cos(f)),
                    - d.x * d.y * (steepness * sin(f))
                );
                binormal += float3(
                    - d.x * d.y * (steepness * sin(f)),
                    d.y * (steepness * cos(f)),
                    - d.y * d.y * (steepness * sin(f))
                );
                return float3(
                    d.x * (a * cos(f)),
                    a * sin(f),
                    d.y * (a * cos(f))
                );
            }
              
            //-----------CalculateFresnel------------------
            float CalculateFresnel(float3 viewDir, float3 normal)
            {
                float R_0 = (_AirRefractiveIndex - _WaterRefractiveIndex) / (_AirRefractiveIndex + _WaterRefractiveIndex);
                R_0 *= R_0;
                return R_0 + (1.0 - R_0) * pow((1.0 - saturate(dot(viewDir, normal))), _FresnelPower);
            }
            
            
            
            
            half3 Highlights(half3 positionWS, half roughness, half3 normalWS, half3 viewDirectionWS)
            {
                //Light mainLight = GetMainLight();
                half roughness2 = roughness * roughness;
                half3 halfDir = normalize(_WorldSpaceLightPos0.xyz + viewDirectionWS);
                half NoH = saturate(dot(normalize(normalWS), halfDir));
                half LoH = saturate(dot(_WorldSpaceLightPos0.xyz, halfDir));
                // GGX Distribution multiplied by combined approximation of Visibility and Fresnel
                half d = NoH * NoH * (roughness2 - 1) + 1.0001;
                half LoH2 = LoH * LoH;
                half specularTerm = roughness2 / ((d * d) * max(0.1, LoH2) * (roughness + 0.5) * 4);
                specularTerm = min(specularTerm, 10);
        
                return specularTerm * _LightColor0.rgb * _shadowAttenuation;
            }
            


            //-------------GetOddNegativeScale----------------
            float GetOddNegativeScale()
            {
                return unity_WorldTransformParams.w;
            }

            //------------CreateTangentToWorld---------------
            float3x3 CreateTangentToWorld(float3 normal, float3 tangent, float flipSign)
            {
            // For odd-negative scale transforms we need to flip the sign
            float sgn = flipSign * GetOddNegativeScale();
            float3 bitangent = cross(normal, tangent) * sgn;

            return float3x3(tangent, bitangent, normal);
            }
            
            //-----------TransformTangentToWorld----------
            float3 TransformTangentToWorld(float3 dirTS, float3x3 tangentToWorld)
            {
                // Note matrix is in row major convention with left multiplication as it is build on the fly
                return mul(dirTS, tangentToWorld);
            }
            float3 GetCameraPositionWS()
            {
                return _WorldSpaceCameraPos;
            }

            //-----------SampleSceneDepth--------------
            sampler2D _CameraDepthTexture;
            sampler2D sampler_CameraDepthTexture;
            float SampleSceneDepth(float2 uv)
            {
                return tex2D(_CameraDepthTexture, UnityStereoTransformScreenSpaceTex(uv)).r;
            }



            float SubsurfaceScattering(float3 viewDir, float3 lightDir, float3 normalDir,
            float frontSubsurfaceDistortion, float backSubsurfaceDistortion, float frontSSSIntensity, float thickness)
            {
            //分别计算正面和反面的次表面散射
            float3 frontLitDir = normalDir * frontSubsurfaceDistortion - lightDir;
            float3 backLitDir = normalDir * backSubsurfaceDistortion + lightDir;
            float frontsss = saturate(dot(viewDir, -frontLitDir));
            float backsss = saturate(dot(viewDir, -backLitDir));
        
            float result = saturate(frontsss * frontSSSIntensity + backsss) * thickness;
            return result;
            }
            /*
            //------------Light------------------
            struct Light
            {
                half3   direction;
                half3   color;
                half    distanceAttenuation;
                half    shadowAttenuation;
            };

            //-----------GetMainLight-------------
            Light GetMainLight()
            {
                Light light;
                light.direction = _WorldSpaceLightPos0.xyz;
                // unity_LightData.z is 1 when not culled by the culling mask, otherwise 0.
                light.distanceAttenuation = unity_LightData.z;
            #if defined(LIGHTMAP_ON) || defined(_MIXED_LIGHTING_SUBTRACTIVE)
                // unity_ProbesOcclusion.x is the mixed light probe occlusion data
                light.distanceAttenuation *= unity_ProbesOcclusion.x;
            #endif
                light.shadowAttenuation = 1.0;
                light.color = _MainLightColor.rgb;

                return light;
            }
            */

            v2f vert (a2v v)
            {
                v2f o;
                float3 tangent = float3(1, 0, 0);
                float3 binormal = float3(0, 0, 1);
                float3 p = v.positionOS;

                p += GerstnerWave(_WaveA, v.positionOS.xyz, tangent, binormal);
                p += GerstnerWave(_WaveB, v.positionOS.xyz, tangent, binormal);
                p += GerstnerWave(_WaveC, v.positionOS.xyz, tangent, binormal);
                p += GerstnerWave(_WaveD, v.positionOS.xyz, tangent, binormal);
                p += GerstnerWave(_WaveE, v.positionOS.xyz, tangent, binormal);
                p += GerstnerWave(_WaveF, v.positionOS.xyz, tangent, binormal);
                p += GerstnerWave(_WaveG, v.positionOS.xyz, tangent, binormal);
                p += GerstnerWave(_WaveH, v.positionOS.xyz, tangent, binormal);
                p += GerstnerWave(_WaveI, v.positionOS.xyz, tangent, binormal);
                p += GerstnerWave(_WaveJ, v.positionOS.xyz, tangent, binormal);
                p += GerstnerWave(_WaveK, v.positionOS.xyz, tangent, binormal);
                p += GerstnerWave(_WaveL, v.positionOS.xyz, tangent, binormal);


                o.heightOS = p.y;
                float3 normal = normalize(cross(binormal, tangent));
                UNITY_INITIALIZE_OUTPUT(v2f, o);
                o.positionCS = UnityObjectToClipPos(p);
                o.normalWS = UnityObjectToWorldNormal(v.normal);
                o.positionWS = mul(unity_ObjectToWorld,v.positionOS);
                o.scrPos = ComputeScreenPos(o.positionCS);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                //UNITY_TRANSFER_FOG(o,o.positionCS);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                float3 normalWS = normalize(i.normalWS);
                //-------------normal----------
                float3x3 TtoW = CreateTangentToWorld(i.normalWS, i.tangentWS, 1);
                fixed4 normalTS = tex2D(_NormalMap,i.uv);
                normalTS.xyz = normalize(UnpackNormal(normalTS));
                normalWS = lerp(normalWS,normalize(TransformTangentToWorld(normalTS,TtoW)),_NormalScale);
                normalWS = normalize(normalWS);

                //------------reflection-----------
                float3 viewDirectionWS = normalize(GetCameraPositionWS() - i.positionWS.xyz);
                float3 reflectDir = reflect(-viewDirectionWS, normalWS);
                float4 reflectCol = texCUBE(_Skybox,reflectDir);
                //------------refraction------------
                float2 scrPos = i.scrPos / i.scrPos.w;
                float depth = SampleSceneDepth(scrPos);
                depth = LinearEyeDepth(depth);
                float surfaceDepth = i.scrPos.w;
                float depthDiffer = depth - surfaceDepth;
                float2 uvOffset = normalWS.xz * _RefractionStrength * saturate(depthDiffer);
                float2 offsetPos = scrPos + uvOffset;
                float offsetPosDepth = SampleSceneDepth(offsetPos);
                offsetPosDepth = LinearEyeDepth(offsetPosDepth);
                offsetPos = scrPos + uvOffset * step(surfaceDepth, offsetPosDepth);
                float4 refractCol = tex2D(_CameraOpaqueTexture,offsetPos);
                float depthFactor = depth / surfaceDepth;
                float3 underPos = (i.positionWS - GetCameraPositionWS()) * depthFactor + GetCameraPositionWS();
                float2 causticSampler = (underPos.xy + underPos.xz + underPos.yz) / 100 * _CausticTex_ST.xy;
                float4 caustic1 = tex2D(_CausticTex, causticSampler + _Caustics_Speed.xy * _Time.y/30);
                float4 caustic2 = tex2D(_CausticTex, causticSampler + _Caustics_Speed.zw * _Time.y/30); 
                float4 caustic = min(caustic1, caustic2);
                
                //------------fog-------------
                float offsetDepthDiffer = surfaceDepth > offsetPosDepth ? depthDiffer: offsetPosDepth - surfaceDepth;
                float fogFactor = saturate(1 - exp(-offsetDepthDiffer * _FogDensity / 10)) * _shadowAttenuation;
                float4 waterCol = lerp(_WaterFogColor, _BaseColor, fogFactor);
                refractCol = lerp(waterCol, waterCol * refractCol, saturate(fogFactor));
                refractCol += caustic * pow((1 - saturate(fogFactor)), 10);

                //------------specular----------
                float3 halfDir = normalize(viewDirectionWS + normalize(_WorldSpaceLightPos0.xyz));
                float3 specular = Highlights(i.positionWS, _Shininess, normalWS, viewDirectionWS);
                specular *= _shadowAttenuation;

                //------------SSS---------------
                float SSSValue = SubsurfaceScattering(viewDirectionWS, _WorldSpaceLightPos0.xyz, normalWS, _FrontSubsurfaceDistortion,
                _BackSubsurfaceDistortion, _FrontSSSIntensity, saturate(i.heightOS - _HeightCorrection));
                SSSValue *= _shadowAttenuation;
                float fresnel = CalculateFresnel(viewDirectionWS, normalWS);
                float4 scatterCol = lerp(refractCol, reflectCol, saturate(fresnel));
                
                float3 shading = scatterCol.rgb + specular + SSSValue * _LightColor0.rgb;

                //------------Foam------------------
                float foamOffset = tex2D(_FoamNoiseTex, i.uv * _FoamNoiseTex_ST.xy + _Time.y).x;
                shading = lerp(shading, float3(0.8, 0.8, 0.8), pow(saturate(_FoamIntensity * foamOffset -depthDiffer) * 2, 3) * saturate(depthDiffer));
                //shading = MixFog(shading.rgb, i.fogFactor);
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv);
                // apply fog
                //UNITY_APPLY_FOG(i.fogCoord, col);
                return (shading.rgb,1);
            }
            ENDCG
        }
    }
}

关于一般水体渲染的技术总结

水体渲染一半的技术节点在于波纹、水体边缘与折射。

波纹笔者现已知的方法有 凹凸纹理贴图、正弦波、FFT这三种,其中最为简单的是纹理贴图只要对其法线进行采样,然后制作UV动画,在制作UV动画的时候,有一个小trick,同一张Normal图可以采样两次,然后分别做主动与扰动。其次是正弦波,正弦波更多的是进行顶点动画,改变定点数据的值来得到,一般的伪代码为 WaveValue = Sin(_Time.y*_Speed+v.vertex.x*_Frequency)*_Amplitude.。期间可以叠加一些常见的sin波或者cos波来进行扰动,他的特点是平滑圆润,适合表达像池塘一样平静的水面。而正弦波的进化版则是Gerstner波,Gerstner波是将水平位置进行挤压,使得波峰变尖,波谷变宽,适合模拟海洋,该公式在《GPU Gems 1》出现过

Gerstner公式

而FTT海洋则是我接下来要研究的对象。之后会另出一篇文章来详解。

水体边缘检测,也就是海浪。海浪我之前有做过两种,一种是描出水体与山体的接触边缘,利用通道区分海浪与海面,相当于做一个mask然后再mask上进行叠加noise图造成扰动效果。另外一种是用深度检测,直接检测出边缘,类似边缘检测的方法。顺带边缘检测一般有几种方法,最基础的方法是用Sobel算子对屏幕图像进行边缘检测,但这种方法会得到许多不希望得到的边缘线而且挺受光源的影响。还有一种卷积检测的方法是Roberts算子,本质就是计算左上角核右下角的差值,乘上右上角和左下角的插值,作为评估依据。取对角方向的深度或者法线值,比较他们之间的插值,如果超过某个阈值,就认为有一条边。而Sobel检测算子是利用相邻像素之间的差值用梯度表示,梯度的绝对值越大,则越有可能是边缘处。这种方法叫做基于图像处理的轮廓渲染。无论怎么改变算子还是其他的,这种方法的劣势是一些深度和法线变化很小的轮廓无法被检出,其他的算法还有

1.基于观察角度和表面法线的轮廓线渲染;这种方法是用视角方向和表面法线的点乘结果得到轮廓信息。可以在一个PASS中得到渲染结果,但局限性很大。

2.过程式几何轮廓渲染;这种方法的核心是用两个PASS渲染,一个剔除正面渲染背面然后沿着法线向外拓展形成轮廓线,还有一个PASS就正常渲染。

3.基于轮廓边检测的轮廓线渲染,这个方法,我们只需要检查和这条边相邻的两个三角面片是否满足 (n0·v>0)≠(n1·v>0),n0与n1分别表示两个相邻三角面片的法向,v是从视角到该边上任意一个顶点的方向。这个条件主要是检车两个相邻的三角面片是否一个正面一个背面。我们可以在几何着色器中完成这个操作,缺点是实现复杂,而且会出现动画连贯性的问题,由于逐帧单独提取轮廓,所以在帧与帧之间会出现条约性。

之前所作的海浪则是通过深度图,需要在摄像机开启深度渲染,基本思路是用摄像机距离地形的距离减去摄像机距离平面的距离,取绝对值,越接近0则越可能是边缘。但有些低端设备不支持深度图渲染,所以用的时候要比较慎用。而且在开启深度纹理的时候需要在FallBack设置为Diffuse才能够体现深度。

接下来是折射

WaterDepth=SceneDepth(Eye)-Screen Position

DepthFade
Movement
Water Color
Water Refraction
Foam
Combine_Color Output