咨询电话:400-810-1418服务与监督电话:400-006-6572

如何用Unity制作逼真的自然场景

发布时间:2020-03-25 12:36:41

游戏中的风景越真实、自然,玩家的代入感也就越强。那如何用Unity制作逼真的自然场景?本文将分析Unity官方制作的演示短片——《Book of the Dead》中,风与植物的交互原理,帮助大家制作出更自然的风景。

0-1.jpg

《Book of the Dead》是由Unity官方制作的一个演示短片,其中有大量的植物,不仅渲染上有着照片级的真实感,而且风与植物的交互也非常的自然。其所有的自然资源都是来自照片扫描技术,而且使用了HDRP高清渲染管线,场景在Unity Asset Store上可以下载得到。本文主要分析短片中风与植物交互的原理。

0-2.gif

一、支持的植物结构

场景中,对于风与植物交互的模拟,支持三种结构:

Hierachy Pivot:层次嵌套Pivot,用于模拟树或者其他有多重层次结构的植物

Single Pivot Color:单Pivot,用于模拟草

Procedural Animation:程序动画,用于模拟浮萍等无pivot的植物

1-1.jpg

对于树的模拟最为复杂,它属于Hierachy Pivot结构,最多支持3个层次嵌套:

主干,连接地面

Level 0分支,连接着主干

Level 1分支,连接着Leval 0分支

1-2.jpg

二、代码入口

本文重点分析Hierachy Pivot结构的实现原理。风与植物的交互一般用程序顶点动画实现,随意找到一棵树的shader,顺藤摸瓜可以在VS中找到如下代码:

可以看到,每个顶点的uv3通道中存的是pivot信息,即该顶点受哪些pivot影响。

#if USE_VEGETATION_ANIM

    float3 positionWS = GetAbsolutePositionWS(positionRWS);

    APPLY_VEGETATION_ANIM_TIMENUDGE(positionWS, normalWS, input.uv3/*pivotData*/, input.color.rgb/*pivotColor*/, GetObjectAbsolutePositionWS(), time.x);

    positionRWS = GetCameraRelativePositionWS(positionWS);

#endif

注意的是,这里的uv3是float3类型。

struct AttributesMesh

{

//...

//forest-begin: Added vertex animation

#if defined(_ANIM_SINGLE_PIVOT_COLOR) || defined(_ANIM_HIERARCHY_PIVOT)

    float3 uv3          : TEXCOORD3;

//...

};

Hierachy Pivot结构的植物,最终调用的是AnimateVegetationHierarchyPivot。

#if defined(USE_VEGETATION_ANIM) && defined(_ANIM_SINGLE_PIVOT_COLOR)

    #define APPLY_VEGETATION_ANIM_TIMENUDGE(worldPos, normalWorld, pivotData, pivotColor, objectRoot, timeNudge) { AnimateVegetationSinglePivot(worldPos, normalWorld, pivotData, pivotColor, timeNudge); }

#elif defined(USE_VEGETATION_ANIM) && defined(_ANIM_HIERARCHY_PIVOT)

    #define APPLY_VEGETATION_ANIM_TIMENUDGE(worldPos, normalWorld, pivotData, pivotColor, objectRoot, timeNudge) { AnimateVegetationHierarchyPivot(worldPos, normalWorld, pivotData, pivotColor, objectRoot, timeNudge); }

#elif defined(USE_VEGETATION_ANIM) && defined(_ANIM_PROCEDURAL_BRANCH)

    #define APPLY_VEGETATION_ANIM_TIMENUDGE(worldPos, normalWorld, pivotData, pivotColor, objectRoot, timeNudge)  { AnimateVegetationProceduralBranch(worldPos, normalWorld, objectRoot, timeNudge); }

三、PivotData解码

pivotData是float3类型,先用asuint转成uint3,一共是32x3=96个bit,分成两段前后48bit,分别存Pivot0和Pivot1的信息,分别用UnpackPivot0和UnpackPivot1解出来。

    uint3 packedData =asuint(pivotData);

    float3 pivotPos0, pivotPos1, pivotFwd0, pivotFwd1;

    bool pivotEnabled0 =UnpackPivot0(packedData, pivotPos0, pivotFwd0);

    bool pivotEnabled1 =UnpackPivot1(packedData, pivotPos1, pivotFwd1);

Pivot0和Pivot1刚好是对称排列。

3-1.png

接下来分析Pivot0是如何解码出来的,48bit里面,高32bit存Pivot Pos,低16位存Pivot Fwd,细节如下图所示。最终解出来的Pos是模型空间的坐标,树的建模应该是树干的根在模型空间的原点,Pos.x和Pos.z是有正负的,而树只能向上长,于是Pos.y必然是大于0。对于一般的树而言垂直方向范围一般大于水平方向的范围,于是用12bit保存Pos.y的值,稍微比x和z多2个bit的精度。

3-2.png

满足packedData.y & 0xFFFF0000时,即高16位有值时,代表有Pivot0的信息,才需要解析。

// Needs to match shader packing in baking tool

bool UnpackPivot0(uint3 packedData, inout float3 pivotPos0, inout float3 pivotFwd0) {

    if(packedData.y & 0xFFFF0000) {

        pivotPos0.x = UnpackFixedToSFloat(packedData.x, 8.f, 10, 22);

        pivotPos0.y = UnpackFixedToUFloat(packedData.x, 32.f, 12, 10);

        pivotPos0.z = UnpackFixedToSFloat(packedData.x, 8.f, 10, 0);

        pivotFwd0.x = UnpackFixedToSFloat(packedData.y, 1.f, 8, 24);

        pivotFwd0.z = UnpackFixedToSFloat(packedData.y, 1.f, 7, 17);

        pivotFwd0.y = sqrt(1.f - saturate(dot(pivotFwd0.xz, pivotFwd0.xz))) * (((packedData.y >> 16) & 1) ? 1.f : -1.f);

        pivotFwd0 = normalize(pivotFwd0);

        return true;

    }

    return false;

}

其中Pos.x用UnpackFixedToSFloat解出来,10bit实际存的是百分比[0, 1],由于x可能是负数,编码时把[-1, 1]映射到[0, 1],于是这里把[0, 1]反映射回[-1, 1],再乘以传入的range,可以看出Pos.x的范围是[-8f, 8f]。从其他硬编码的参数可以看出,树的建模尺寸是长宽16x16,高是32。

float UnpackFixedToSFloat(uint val, float range, uint bits, uint shift) {

    const uint BitMask = (1 << bits) - 1;

    val = (val >> shift) & BitMask;

    float fval = val / (float)BitMask;

    return (fval * 2.f - 1.f) * range;

}

Fwd是分支(树干或者树枝)的方向,由于是单位向量,所以只存了x和z分量,y分量可以通过公式反算出来,开方后丢失了符号信息,于是用1位存符号。

pivotFwd0.x = UnpackFixedToSFloat(packedData.y, 1.f, 8, 24);

        pivotFwd0.z = UnpackFixedToSFloat(packedData.y, 1.f, 7, 17);

        pivotFwd0.y = sqrt(1.f - saturate(dot(pivotFwd0.xz, pivotFwd0.xz))) * (((packedData.y >> 16) & 1) ? 1.f : -1.f);

        pivotFwd0 = normalize(pivotFwd0);

四、整体流程

伪代码如下所示,有点跟骨骼动画类似,顶点受骨骼的变换影响,而每个骨骼会受其父骨骼的变换影响,最终顶点受骨骼的级联变换影响。树的顶点至少受主干的影响,因为任何顶点肯定要么是属于主干或者属于其他分支,而其他分支必然直接或间接连着主干,最复杂的情况是顶点在level1分支上,level1分支连着level0分支,level分支连着主干,需要计算累计变换。

//任何顶点肯定是在主干上或连接着主干

    计算主干受风力影响导致的旋转;

    旋转作用于顶点pos和normal;

    if (有pivot0信息)//主干连接着level0分支

    {

        计算level0分支受风力影响导致的旋转;

        旋转作用于顶点pos和normal;

        if (有pivot1信息)//level0分支连接着level1分支

        {

            计算level1分支受风力影响导致的旋转;

            旋转作用于顶点pos和normal;

        }

    }

4.1 主干受风影响

对于每个枝干受风吹后弯曲程度,由如下变量控制,整体的弯曲程度可以由Wind Elasticity Lvl x系列变量控制,其中Lvl B是主干。

4-1.png

树被风吹有个特点,离地面越远部分,被吹弯曲的越厉害,所以会有个缩放系数来控制旋转量,地面处为0(树根),离地面越远的部分这个缩放系数越大。有种预烘培做法,是用顶点模型空间的y除以整个树的高度,计算出缩放系数并把它烘到点色或者其他通道上,这里的做法是运行时计算,用变量_WindRangeLvlB调节受风的范围,它是模型空间的量,其实跟预烘培的效果差不多。lvBElasticity是最终的弹性缩放系数。

//主干风力影响代码

    float lvBRelativeObjectScale = mul(GetActualObject2World(), float4(0, _WindRangeLvlB, 0, 0)).y;

    float3 windFwd = GetWindDirection(objectRoot);

    float3 lvBBaseGustWind = GetTreeBaseGustWind(objectRoot, timeNudge);

    float3 lvBPos = objectRoot;

    //主干fwd直接取模型空间y轴方向

    float3 lvBFwd = float3(0, 1, 0); //TODO: grab from rotation matrix

    float lvBElasticity = _WindElasticityLvlB;

    float lvBDistScale = saturate((worldPos.y - objectRoot.y) / lvBRelativeObjectScale);

    lvBElasticity *= lvBDistScale;

对于风吹草的模拟一般在顶点加上风力方向的偏移就可以得到比较好的效果,因为一般草都比较矮小,但是对于树这种比较高的复杂结构,用草的方式模拟会有种树被拉扯变长的感觉,所以一般的方案是用旋转代替顶点偏移。

lvBWindAxis为旋转轴,windFwd与lvBFwd如果同向或者反向时候,主干应该是不会旋转,后面的枝干level 0做了这种情况的修正,主干这里可能从设计上就不会有垂直于地面的风向吧。

然后就是把旋转作用到顶点的pos和normal上,旋转的锚点是世界空间下的objectRoot,应该是模型空间的原点。

    float lvBWindRotAngle = lvBBaseGustWind.x * lvBElasticity;

    //对旋转角度进行log2衰减

    lvBWindRotAngle = log2(1.f + abs(lvBWindRotAngle)) * sign(lvBWindRotAngle);

    float3 lvBWindAxis = cross(lvBFwd, windFwd);

    float4 lvBWindQuat = QuaternionFromAxisAngle(lvBWindAxis, lvBWindRotAngle);

    worldPos = QuaternionRotatePointAbout(worldPos, lvBPos, lvBWindQuat);

    worldNrm = QuaternionRotateVector(worldNrm, lvBWindQuat);

4.2 支干Level0受风影响

逻辑基本与主干差不多,不同的地方是lv0Fwd的方向用lv0BaseGustWind和lvBDistScale做了调整,猜测是为了让弯曲旋转更自然。

旋转角度lv0WindRotAngle根据windFwd和lv0Fwd的是否平行,进行了相应的衰减。

    //当lv0BaseGustWind.y为0时,平行风,旋转轴为y轴模拟更自然

    lv0Fwd.y *= lv0BaseGustWind.y * lvBDistScale;

    lv0Fwd = normalize(lv0Fwd);

    float3 lv0WindAxis = cross(windFwd, lv0Fwd);

    float3 lv0WindRight = cross(windFwd, lv0WindAxis);

    float lv0PerpendicularFactor = dot(lv0Fwd, lv0WindRight);

    float lv0AngleFactor = lv0PerpendicularFactor * lv0PerpendicularFactor;

    lv0AngleFactor *= sign(lv0PerpendicularFactor);

    lv0WindRotAngle *= lv0AngleFactor;

4.3 支干Level1受风影响

Level1是树最外层的部分了,整体流程与LevelB和Level0差不多,另外多了一些扑动的处理,树的外端末枝(树叶或者小树枝)被风吹的时候往往是有较剧烈的摇晃,而且呈一定的随机周期性运动,这部分计算出来的是顶点偏移,并在最后的旋转前先加到顶点坐标上。

    float vertexFlutterPhase = dot(worldPos, _WindFlutterPhase);

    float windFlutterCos = cos(WIND_PI2 * (_WindTime + timeNudge + vertexFlutterPhase) / (_WindTreeFlutterGustVariancePeriod * _WindFlutterPeriodScale));

    float windFlutterStrength = lv1Elasticity * _WindFlutterElasticity * _WindFlutterScale * (_WindTreeFlutterStrength + saturate((max(0.f, lv0BaseGustWind.z) - _WindTreeFlutterGustStrengthOffset) / _WindTreeFlutterGustStrengthScale) * _WindTreeFlutterGustStrength);

    float3 lv1WindAxis = cross(windFwd, lv1Fwd);

    worldPos += lv1WindAxis * windFlutterCos * windFlutterStrength;

文 | Kirk

腾讯互动娱乐 工程师