从源码深入理解Unreal动画系统

在这里插入图片描述

前言

最近在用自定义格式的动画数据,所以需要实现自己的动画蓝图节点做数据处理,于是顺便整理了一下引擎是如何从SkeletalMeshComponent一步一步执行到每一个动画蓝图节点的,分析以源码主要流程为重,像RootMotion等附加功能后续遇到问题再来补充。

USkinnedMeshComponent::TickComponent

动画系统的更新是从SkinnedMeshComponent的Tick开始,如果涉及到RootMotion的话CharacterMovementComponent也会发起更新,但为了简化我们先不管RootMotion相关的事情

	// Tick Pose first
	if (ShouldTickPose())
	{
		TickPose(DeltaTime, false);
	}

	// If we have been recently rendered, and bForceRefPose has been on for at least a frame, or the LOD changed, update bone matrices.
	if( ShouldUpdateTransform(bLODHasChanged) )
	{
		// Do not update bones if we are taking bone transforms from another SkelMeshComp
		if( LeaderPoseComponent.IsValid() )
		{
			UpdateFollowerComponent();
		}
		else 
		{
			RefreshBoneTransforms(ThisTickFunction);
		}
	}

可以非常明显的看到,动画系统的更新分为两个大部分Tick(Update)Evaluate

Tick(Update) 指的是动画数据的计算,包括动画蓝图中使用到的AnimMontage状态更新、EventGraph运行以及AnimGraph中每个节点的状态更新,对应代码中的TickPose
Evaluate 指的是从动画数据到骨骼Transform的计算,主要是依次对AnimGraph中每个节点的Evaluate计算得到OutputPose,对应代码中的RefreshBoneTransform

当设置了LeaderPoseComponent时不会自己进行Tick和Evaluate,而是由LeaderPoseComponent进行调用;Evaluate可能因为遮挡或者上一帧结果尚未渲染而取消执行

TickPose

TickPose在SkeletalMeshComponent中实际上就是调用了TickAnimation,而TickAnimation实际上就是TickAnimInstance

void USkeletalMeshComponent::TickPose(float DeltaTime, bool bNeedsValidRootMotion)
{
	if (ShouldTickAnimation())
	{
		// Don't care about roll over, just care about uniqueness (and 32-bits should give plenty).
		LastPoseTickFrame = static_cast<uint32>(GFrameCounter);

		float DeltaTimeForTick;
		if(bExternalTickRateControlled)
		{
			DeltaTimeForTick = ExternalDeltaTime;
		}
		else if(ShouldUseUpdateRateOptimizations())
		{
			DeltaTimeForTick = DeltaTime + AnimUpdateRateParams->GetTimeAdjustment();
		}
		else
		{
			DeltaTimeForTick = DeltaTime;
		}

		TickAnimation(DeltaTimeForTick, bNeedsValidRootMotion);
	}
}
void USkeletalMeshComponent::TickAnimation(float DeltaTime, bool bNeedsValidRootMotion)
{
	if (GetSkeletalMeshAsset() != nullptr && !bIsCompiling)
	{
		TickAnimInstances(DeltaTime, bNeedsValidRootMotion);
	}
}

TickAnimInstance首先会Tick在自己的AnimInstance中使用到的LinkedAnimInstance,然后是自己的AnimInstance和在Skeleton Mesh中设置的PostProcessAnimInstance(如果Enable PP的话)

void USkeletalMeshComponent::TickAnimInstances(float DeltaTime, bool bNeedsValidRootMotion)
{
	// Allow animation instance to do some processing before the linked instances update
	if (AnimScriptInstance != nullptr)
	{
		AnimScriptInstance->PreUpdateLinkedInstances(DeltaTime);
	}

	// We update linked instances first incase we're using either root motion or non-threaded update.
	// This ensures that we go through the pre update process and initialize the proxies correctly.
	for (UAnimInstance* LinkedInstance : LinkedInstances)
	{
		// Sub anim instances are always forced to do a parallel update 
		LinkedInstance->UpdateAnimation(DeltaTime * GlobalAnimRateScale, false, UAnimInstance::EUpdateAnimationFlag::ForceParallelUpdate);
	}

	if (AnimScriptInstance != nullptr)
	{
		// Tick the animation
		AnimScriptInstance->UpdateAnimation(DeltaTime * GlobalAnimRateScale, bNeedsValidRootMotion);
	}

	if(ShouldUpdatePostProcessInstance())
	{
		PostProcessAnimInstance->UpdateAnimation(DeltaTime * GlobalAnimRateScale, false);
	}
}

TickAnimInstance

从这里开始我们就进入到AnimInstance的Tick中,抛去掉一些Subsystem事件和Editor的东西,这里的逻辑其实非常简单,参考开头的图这里可以分为PreUpdate、UpdateMontage、NativeUpdate、BlueprintUpdate、ParallelUpdate和PostUpdate这六步

void UAnimInstance::UpdateAnimation(float DeltaSeconds, bool bNeedsValidRootMotion, EUpdateAnimationFlag UpdateFlag)
{
	// acquire the proxy as we need to update
	FAnimInstanceProxy& Proxy = GetProxyOnGameThread<FAnimInstanceProxy>();

	// Apply any pending dynamics reset
	if(PendingDynamicResetTeleportType != ETeleportType::None)
	{
		Proxy.ResetDynamics(PendingDynamicResetTeleportType);
		PendingDynamicResetTeleportType = ETeleportType::None;
	}
	if (const USkeletalMeshComponent* SkelMeshComp = GetSkelMeshComponent())
	{
		/**
			If we're set to OnlyTickMontagesWhenNotRendered and we haven't been recently rendered,
			then only update montages and skip everything else. 
		*/
		if (SkelMeshComp->ShouldOnlyTickMontages(DeltaSeconds))
		{
			UpdateMontage(DeltaSeconds);
			return;
		}
	}
	PreUpdateAnimation(DeltaSeconds);

	// need to update montage BEFORE node update or Native Update.
	// so that node knows where montage is
	{
		UpdateMontage(DeltaSeconds);

		// now we know all montage has advanced
		// time to test sync groups
		UpdateMontageSyncGroup();

		// Update montage eval data, to be used by AnimGraph Update and Evaluate phases.
		UpdateMontageEvaluationData();
	}

	{
		NativeUpdateAnimation(DeltaSeconds);
	}
	{
		BlueprintUpdateAnimation(DeltaSeconds);
	}

	if(bShouldImmediateUpdate)
	{
		// cant use parallel update, so just do the work here (we call this function here to do the work on the game thread)
		ParallelUpdateAnimation();
		PostUpdateAnimation();
	}
}
AnimInstanceProxy

这里要先解释一下什么是AnimInstanceProxy,因为可以说在接下来的Tick流程包括后面的Evaluate中用到的绝大多数都会是FAnimInstanceProxy而不是UAnimInstance。下面是官方文档上的解释,其实非常清楚,根本原因就是动画的多线程与线程安全,因为AnimGraph的Update/Evaluate可能运行在非GameThread上,所以需要提供一份数据副本来给AnimGraph读写,然后数据副本Delta每帧的换入换出由GameThread来完成(PreUpdate/PreEvaluate/PostUpdate),这样保证了UAnimInstance内数据的线程安全。

The main driver behind this was to control data access more tightly across threads. To this end, much anim-graph-accessed data has been moved from UAnimInstance to a new struct called FAnimInstanceProxy. This proxy structure is where the bulk of the data on UAnimInstance is found.

In general, the UAnimInstance should not be accessed or mutated from within AnimGraph nodes (Update/Evaluate calls) as these can be run on other threads. There are locking wrappers (GetProxyOnAnyThread and GetProxyOnGameThread) that prevent access to the FAnimInstanceProxy while tasks are in-flight. The idea is that in the worst case, tasks wait for completion before data is allowed to be read from or written to in the proxy.

From the Anim Graph point of view, only the FAnimInstanceProxy can be accessed from animation nodes, not the UAnimInstance. Data must be exchanged with the proxy for each tick (either through buffering, copying or some other strategy) in FAnimInstanceProxy::PreUpdate or FAnimInstaceProxy::PreEvaluateAnimation. Any data that then needs to be accessed by external objects should then be exchanged/copied from the proxy in FAnimInstanceProxy::PostUpdate.

这里最后还给出了关于AnimInstance的代码建议,应当是动画蓝图监听GameLogic,而不是GameLogic直接调用或修改AnimInstance。这个其实也适用于UI等,应当是UMG去query GameLogic,Unreal中很多模块都有类似的设计思路,线程安全只是优点之一

This is in conflict with the general use case of UAnimInstance where member variables can be accessed from other classes while tasks are in-flight. As a recommendation, try to not directly access the Anim Instance at all from other classes. Instead, the Anim Instance should pull data from elsewhere.

关于Proxy中具体包括的数据直接去FAnimInstanceProxy中看就可以了,在接下来的流程中也可以看到各个数据的用途,这里不赘述

Montage

先看TickMontage,因为它其实和AnimInstance的流程关系不大,是相对独立的一部分
对于未渲染的AnimInstance,通过ShouldOnlyTickMontage判断是否只TickMontage
TickMontage也是分为Tick(UpdateMontage)和Evaluate(UpdateMontageEvaluationData)
UpdateMontage主要负责计算当前的播放Position,在各个Section之间的blend切换,以及触发Event等
UpdateMontageEvaluationData把计算好的结果数据打包到Proxy中,供后续AnimInstance读取计算Pose

void UAnimInstance::UpdateMontageEvaluationData()
{
	FAnimInstanceProxy& Proxy = GetProxyOnGameThread<FAnimInstanceProxy>();

	Proxy.GetMontageEvaluationData().Reset(MontageInstances.Num());

	for (FAnimMontageInstance* MontageInstance : MontageInstances)
	{
		// although montage can advance with 0.f weight, it is fine to filter by weight here
		// because we don't want to evaluate them if 0 weight
		if (MontageInstance->Montage && MontageInstance->GetWeight() > ZERO_ANIMWEIGHT_THRESH)
		{
			Proxy.GetMontageEvaluationData().Add(
				FMontageEvaluationState
				(
					MontageInstance->Montage,
					MontageInstance->GetPosition(),
					MontageInstance->DeltaTimeRecord,
					MontageInstance->bPlaying,
					MontageInstance->IsActive(),
					MontageInstance->GetBlend(),
					MontageInstance->GetActiveBlendProfile(),
					MontageInstance->GetBlendStartAlpha()
				));
		}
	}

	Proxy.GetSlotGroupInertializationRequestMap() = SlotGroupInertializationRequestMap;
}

其实这里只是处理了Montage相比Sequence多出的那些事件、Section等功能,并不会实际Evaluate出Transform,所有的Pose计算还是要到AnimGraph中去,具体可以看AnimNode_Slot的Update和Evaluate

PreUpdate和PostUpdate

这俩的作用已经在Proxy那里介绍过了,就是做Proxy和AnimInstance间的数据交换,逻辑也很简单,就是调用Proxy的PreUpdate和PostUpdate然后把UAnimInstance作为参数传入

NativeUpdateAnimation和BlueprintUpdateAnimation

BlueprintUpdateAnimation就是动画蓝图EventGraph中的EventBlueprintUpdateAnimation
NativeUpdateAnimation就是在cpp类中的Logic Update

ParallelUpdateAnimation

到这里我们才开始真正进入“动画”的部分,开始和各种Pose打交道,开始在非GameThread上工作,开始进入到AnimGraph连接好的各个节点中去,之前都是在准备数据和计算与GameLogic相关的变量
AnimGraph的执行从FAnimInstanceProxy::UpdateAnimationWithRoot开始,

void FAnimInstanceProxy::UpdateAnimation_WithRoot(const FAnimationUpdateContext& InContext, FAnimNode_Base* InRootNode, FName InLayerName)
{
	if(InRootNode == RootNode)
	{
		if(bDeferRootNodeInitialization)
		{
			InitializeRootNode_WithRoot(RootNode);

			if(AnimClassInterface)
			{
				// Initialize linked sub graphs
				for(const FStructProperty* LayerNodeProperty : AnimClassInterface->GetLinkedAnimLayerNodeProperties())
				{
					if(FAnimNode_LinkedAnimLayer* LayerNode = LayerNodeProperty->ContainerPtrToValuePtr<FAnimNode_LinkedAnimLayer>(AnimInstanceObject))
					{
						if(UAnimInstance* LinkedInstance = LayerNode->GetTargetInstance<UAnimInstance>())
						{
							FAnimationInitializeContext InitContext(this);
							LayerNode->InitializeSubGraph_AnyThread(InitContext);
							FAnimationCacheBonesContext CacheBonesContext(this);
							LayerNode->CacheBonesSubGraph_AnyThread(CacheBonesContext);
						}
					}
				}
			}

			bDeferRootNodeInitialization = false;
		}

		// Call the correct override point if this is the root node
		CacheBones();
	}
	else
	{
		CacheBones_WithRoot(InRootNode);
	}

	// Update root
	{
		// update all nodes
		if(InRootNode == RootNode)
		{
			// Call the correct override point if this is the root node
			UpdateAnimationNode(InContext);
		}
		else
		{
			UpdateAnimationNode_WithRoot(InContext, InRootNode, InLayerName);
		}
	}
}

除了对继承LinkedAnimLayerInterface的情况有些特殊处理外,基本可以分为三个部分Initialize、CacheBones、UpdateAnimationNode
对应着AnimNode中常见的几个重载函数,这里以与播放Montage相关的节点FAnimNode_Slot为例

USTRUCT(BlueprintInternalUseOnly)
struct ANIMGRAPHRUNTIME_API FAnimNode_Slot : public FAnimNode_Base
{
	GENERATED_USTRUCT_BODY()

	// The source input, passed thru to the output unless a montage or slot animation is currently playing
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=Links)
	FPoseLink Source;

	// The name of this slot, exposed to gameplay code, etc...
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=Settings, meta=(CustomizeProperty))
	FName SlotName;

	//Whether we should continue to update the source pose regardless of whether it would be used.
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Settings)
	bool bAlwaysUpdateSourcePose;

protected:
	FSlotNodeWeightInfo WeightData;
	FGraphTraversalCounter SlotNodeInitializationCounter;

public:	
	FAnimNode_Slot();

	// FAnimNode_Base interface
	virtual void Initialize_AnyThread(const FAnimationInitializeContext& Context) override;
	virtual void CacheBones_AnyThread(const FAnimationCacheBonesContext& Context) override;
	virtual void Update_AnyThread(const FAnimationUpdateContext& Context) override;
	virtual void Evaluate_AnyThread(FPoseContext& Output) override;
	virtual void GatherDebugData(FNodeDebugData& DebugData) override;
	// End of FAnimNode_Base interface
};
AnimGraph的遍历

每个AnimGraph都会有个RootNode,也就是这个OutputPose节点,它对应的类是FAnimNode_Root
在这里插入图片描述
上面的Initialize、CacheBone、Update包括后面的Evaluate都是从这个RootNode开始的,由它向前执行连接的节点
AnimGraph中所有的输入(或者说连接)都对应了一个FPoseLink,如果没有连接的话那它会为Nullptr。所有需要输入的节点都会在成员变量中声明这个PoseLink,对于像Blend节点可能会有多个PoseLink,而像资产播放节点FAnimNode_AssetPlayerBase就没有PoseLink
除了OutputPose(RootNode)外所有节点都有输出Pose,而且如果不跟其他节点的输入Pose相连这个输出也没有意义,所以输出Pose是不需要声明PoseLink的
每个节点都通过自己的PoseLink找到或选择前继节点,先执行前继节点直到为空,也就是说找的过程实际上是与执行过程相反的。在源码中你会经常看到AnimationTree这个概念,它形容的其实就是AnimGraph,本质上就是一个以Output Pose为根的树,每次Tick都做树的深度优先遍历

void FAnimNode_Slot::Evaluate_AnyThread(FPoseContext & Output)
{
	DECLARE_SCOPE_HIERARCHICAL_COUNTER_ANIMNODE(Evaluate_AnyThread)
	// If not playing a montage, just pass through
	if (WeightData.SlotNodeWeight <= ZERO_ANIMWEIGHT_THRESH)
	{
		Source.Evaluate(Output);
	}
	else
	{
		FPoseContext SourceContext(Output);
		if (WeightData.SourceWeight > ZERO_ANIMWEIGHT_THRESH)
		{
			Source.Evaluate(SourceContext);
		}

		const FAnimationPoseData SourcePoseData(SourceContext);
		FAnimationPoseData OutputPoseData(Output);
		Output.AnimInstanceProxy->SlotEvaluatePose(SlotName, SourcePoseData, WeightData.SourceWeight, OutputPoseData, WeightData.SlotNodeWeight, WeightData.TotalNodeWeight);
	}
}

以上面FAnimNode_Slot的Evaluate策略为例,如果当前Montage的Weight为0(这个Weight是在Update时计算获取的值),就直接使用前继节点的输出Pose;否则就先计算前继节点Pose再根据Weight计算Montage的Pose做Blend。无论是Evaluate还是Update等,遍历过程中的所有结果都储存在传入的Context中,这个Context在节点间流动作为共享的数据类似行为树的黑板

UpdateAnimation

总的来说,Initialize、CacheBones、Update这几步只是一个遍历每个节点的流程,具体取决于每个节点自己的逻辑。但一般来说,Initialize中是做一些自身的初始化和其他对象无关,CacheBones会通过传入的参数拿到当前AnimInstance所对应的Skeleton以及SkeletonMesh资产以及相关数据做一些自己的数据准备,Update会计算出Evaluate时需要直接使用的数据并且开始和前继节点相关联

RefreshBoneTransforms

RefreshBoneTransforms首先会判断AnimInstance和PPAnimInstance是否已Update,如果没有的话会做Update
然后就是对AnimInstance调用PreEvaluate、Evaluate和PostEvaluate,其中PreEvaluate和PostEvaluate类似于Tick时的PreUpdate和PostUpdate,主要是用于构建Proxy和进行数据交换。Evaluate可能通过DispatchParallelEvaluationTask到其他线程上执行,也可能直接调用DoParallelEvaluationTasks_OnGameThread在GameThread上执行

	if (bShouldDoEvaluation)
	{
		// If we need to eval the graph, and we're not going to update it.
		// make sure it's been ticked at least once!
		{
			bool bShouldTickAnimation = false;		
			if (AnimScriptInstance && !AnimScriptInstance->NeedsUpdate())
			{
				bShouldTickAnimation = !AnimScriptInstance->GetUpdateCounter().HasEverBeenUpdated();
			}

			bShouldTickAnimation = bShouldTickAnimation || (ShouldPostUpdatePostProcessInstance() && !PostProcessAnimInstance->GetUpdateCounter().HasEverBeenUpdated());

			if (bShouldTickAnimation)
			{
				// We bypass TickPose() and call TickAnimation directly, so URO doesn't intercept us.
				TickAnimation(0.f, false);
			}
		}

		// If we're going to evaluate animation, call PreEvaluateAnimation()
		{
			DoInstancePreEvaluation();
		}
	}

	if (bDoParallelEvaluation)
	{
		QUICK_SCOPE_CYCLE_COUNTER(STAT_USkeletalMeshComponent_RefreshBoneTransforms_SetupParallel);

		DispatchParallelEvaluationTasks(TickFunction);
	}
	else
	{
		if (AnimEvaluationContext.bDoEvaluation || AnimEvaluationContext.bDoInterpolation)
		{
			QUICK_SCOPE_CYCLE_COUNTER(STAT_USkeletalMeshComponent_RefreshBoneTransforms_GamethreadEval);

			DoParallelEvaluationTasks_OnGameThread();
		}
		else
		{
			if(AnimScriptInstance && AnimScriptInstance->NeedsUpdate())
			{
				AnimScriptInstance->ParallelUpdateAnimation();
			}

			if(ShouldPostUpdatePostProcessInstance())
			{
				PostProcessAnimInstance->ParallelUpdateAnimation();
			}
		}

		PostAnimEvaluation(AnimEvaluationContext);
		AnimEvaluationContext.Clear();
	}

ParallelEvaluateAnimation

不论在哪个线程上,最终本质上是调用了UAnimInstance::ParallelEvaluateAnimation,然后调用FAnimInstanceProxy::EvaluateAnimation

void FAnimInstanceProxy::EvaluateAnimation(FPoseContext& Output)
{
	EvaluateAnimation_WithRoot(Output, RootNode);
}
void FAnimInstanceProxy::EvaluateAnimation_WithRoot(FPoseContext& Output, FAnimNode_Base* InRootNode)
{
	if(InRootNode == RootNode)
	{
		// Call the correct override point if this is the root node
		CacheBones();
	}
	else
	{
		CacheBones_WithRoot(InRootNode);
	}

	// Evaluate native code if implemented, otherwise evaluate the node graph
	if (!Evaluate_WithRoot(Output, InRootNode))
	{
		EvaluateAnimationNode_WithRoot(Output, InRootNode);
	}
}

到这里其实就和之前TickPose执行到AnimGraph类似了,只不过这次是先CacheBones然后Evaluate,遍历依然是从RootNode开始通过PoseLink向前搜索执行。

PoseSpace

之前Update时说过,每个有输入的节点都会声明Poselink,这个其实不准确,在AnimGraph中实际上有两种PoseLink,分别代表两个空间。对于Update等步骤来说它们没什么区别,只是一个寻找前继节点的指针,但对Evaluate来说是非常关键的。
在这里插入图片描述
默认的PoseLink是白色的,表示LocalSpace也就是父骨骼空间,OutputPose就是需要在这个空间下;蓝色的为ComponentSpace,也就是整个骨骼Component的坐标系下,SkeletalControl节点就是使用的这种PoseLink。
这两种空间下的Pose是不能之间相连的,必须通过FAnimNode_ConvertComponentToLocalSpace或者FAnimNode_ConvertComponentToLocalSpace做转换。输出ComponentSpace的节点需要重载EvaluateComponentSpace_AnyThread,传入的Context也是不同的

USTRUCT(BlueprintInternalUseOnly)
struct ANIMGRAPHRUNTIME_API FAnimNode_SkeletalControlBase : public FAnimNode_Base
{
	GENERATED_USTRUCT_BODY()

	// Input link
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=Links)
	FComponentSpacePoseLink ComponentPose;

	virtual void EvaluateComponentSpace_AnyThread(FComponentSpacePoseContext& Output) final;
}

具体Evaluate函数重载的案例上面已经介绍了一个AnimNode_Slot了,一般来说就是先Evaluate前继节点,然后再执行自己,结果都Output到Context中

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
unreal media capture 是一款用于捕获和编码视频和音频的工具,其源码分析主要涉及到软件的基本架构、核心功能和关键算法等方面。 首先,unreal media capture 的基本架构包括三个主要模块:媒体捕获模块、媒体编码模块和媒体传输模块。媒体捕获模块负责从摄像头或屏幕等设备中获取原始音视频数据;媒体编码模块将原始数据进行压缩编码,以提高数据传输效率和减少带宽占用;媒体传输模块负责将编码后的数据传输到目标设备或网络。 其次,unreal media capture 的核心功能包括音视频流的捕获、编码和传输等。捕获功能通过与音视频设备的交互,获取原始的音视频数据流;编码功能将原始数据进行压缩编码,以减小数据体积和提高传输效率;传输功能将编码后的数据流传输到指定设备或网络。 另外,unreal media capture 的源码分析还需深入理解其中的关键算法。例如,音视频的编码算法主要采用诸如H.264、AAC等标准的编码算法,需要了解其原理和应用;传输模块中的网络传输算法需要熟悉网络协议和数据传输机制,如UDP或TCP等;另外,还需要分析源码中的数据流处理算法,以及视频帧率、分辨率和音频采样率等参数的处理方式。 总之,unreal media capture 源码分析需要对软件的基本架构、核心功能和关键算法等方面有深入的理解。只有通过深入分析源码,我们才能全面了解该工具的原理和功能,进而进行二次开发或优化工作。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值