|
发表于 2022-12-30 16:36:35
|
显示全部楼层
图文无关 UE4.26后更改了自身Sequence的计算方式,可参见 大规模内容的性能保障:虚幻引擎4.26中的Sequencer 一文。阅读本文后,您将能大致了解ECS在源码层面是如何实现的,本文使用的原生引擎版本为5.0.3,相较于4.26,更多的Section在UE5中接入了ECS系统,System也得到了大量补充,但目前本文不会介绍具体的System功能。
文中如有错误欢迎指正。
1. 从何处进入ECS?
创建LevelSequence资产,添加任意Actor及轨道,在其求值或ImportEntityImpl函数中打断点(例如:Transform轨道),可以看到一个类似下图的堆栈内容:

图1. 引入Entity时的部分堆栈
可见MovieSceneEntitySystemRunner由Flush()调用DoFlushUpdateQueueOnce(),此处我们可以看到UE写的入口点注释。

图2. FMovieSceneEntitySystemRunner::DoFlushUpdateQueueOnce()
对照官方介绍,可以看到Sequence四个更新阶段均在此类中以GameThread_...Phase形式记载,函数体内均包含代码Linker->SystemGraph.ExecutePhase(ESystemPhase, Linker, AllTasks),即是各系统执行入口。


图3. FMovieSceneEntitySystemRunner::GameThread_...Phase()
为了介绍流程,需要大致了解下各个类的持有关系、简单功能等。主要会围绕以下的结构展开。

图4. 大致的结构
2. Linker及其相关的初始化过程
我们将从在编辑器下打开一个LevelSequence资产的流程为例,并以此窥探各类间的联系。FSequencer继承自IMovieScenePlayer,是Sequence资产的默认编辑器,FSequencer::InitSequencer(...)方法在默认构造函数后被调用以初始化各个配置,构造全局UMovieSceneCompiledDataManager并持有RootEvaluationTemplateSequence。前者在ECS过程中主要用于将整条Sequence划分为多个求值区间,在UMovieSceneSequencerPlayer的过程中由RootEvaluationTemplateSequence创建,多包含了网络相关标记,下文几乎不会介绍此类。

图5. FSequencer::InitSequencer(...)
RootEvaluationTemplateSequence用于整体调度各个工作成员,并为外部提供数据访问接口。

图6. RootEvaluationTemplateSequence保留的成员
在其Initialize(...)函数中创建Linker与Runner,并将二者互相绑定。Linker的位置可由IMovieScenePlayer指定,或直接New在临时包下。
EntitySystemLinker = ConstructEntityLinker(Player);
EntitySystemRunner.AttachToLinker(EntitySystemLinker);
RootInstanceHandle = EntitySystemLinker->GetInstanceRegistry()->AllocateRootInstance(&Player);
在LInker的cpp文件顶定义了具有FComponentRegistry类型的全局GComponentRegistry,在EntityManager中提供了其指针。
// UMovieSceneEntitySystemLinker::UMovieSceneEntitySystemLinker(...)
EntityManager.SetComponentRegistry(&GComponentRegistry);
InstanceRegistry.Reset(new FInstanceRegistry(this));
新的FInstanceRegistry被创建,并随后将RootSequence信息记入自身。值得一提的是Sequence中大部分情况都类似于此传递包装了Index的Handle,使用方凭Handle即可在容器中找到数据。TSparseArray重载其new操作符,将新的SequenceInstance存入NewAllocation的指针中。
// FInstanceRegistry::AllocateRootInstance(...)
check(Instances.Num() < 65535);
const uint16 InstanceSerial = InstanceSerialNumber++;
FSparseArrayAllocationInfo NewAllocation = Instances.AddUninitialized();
FInstanceHandle InstanceHandle { (uint16)NewAllocation.Index, InstanceSerial };
new (NewAllocation) FSequenceInstance(Linker, Player, InstanceHandle);
return InstanceHandle;
传入SequenceInstance进行构造的参数实际上可以拿到全部的Sequence信息,委托指示此Sequence绑定丢失时标记Guid,Invalidate函数内由CompiledDataManager进行区间划分使其掌握轨道与段落信息,并为SequenceUpdater赋值。ISequenceUpdater有两个具体实现,按Sequence是否有层级来生成不同的实例,本文以最简单的无层级时的FSequenceUpdater_Flat做介绍。
// FSequenceInstance::FSequenceInstance(...)
CompiledDataID = Player->GetEvaluationTemplate().GetCompiledDataID();
FMovieSceneObjectCache& ObjectCache = Player->State.GetObjectCache(SequenceID);
OnInvalidateObjectBindingHandle = ObjectCache.OnBindingInvalidated.AddUObject(Linker,
&UMovieSceneEntitySystemLinker::InvalidateObjectBinding, InstanceHandle);
InvalidateCachedData(Linker);
到此,初始化相关基本完成,后续即将在FSequencer::Tick(...)或UMovieScenePlayer::Play(...)中被调执行,此时Runner将调用Linker各个阶段的系统执行,下一部分将从Runner::GameThread_SpawnPhase()开始介绍Linker相关的类型结构。
2.2 Linker相关的类型关系
进入后我们直奔执行点 GameThread_SpawnPhase(),在当前阶段,Linker主要做了两件事,链接System与执行System。
// FMovieSceneEntitySystemRunner::GameThread_SpawnPhase()
Linker->AutoLinkRelevantSystems();
// -> UMovieSceneEntitySystemLinker::LinkRelevantSystems()
UMovieSceneEntitySystem::LinkRelevantSystems(this);
执行到这里,我们见到了ECS中System的基类UMovieSceneEntitySystem,通过调用其静态方法,完成了所有System的链接。查看这一函数,可以发现他是通过全局变量GlobalDependencyGraph中的所有GraphID获取了保存的Class,并继续下去。那GlobalDependencyGraph是什么时候保存的这些ID与类型呢?
// UMovieSceneEntitySystem::LinkRelevantSystems(...)
EEntitySystemContext LinkerContext = InLinker->GetSystemContext();
for (uint16 GraphID = 0; GraphID < GlobalDependencyGraph.NumGraphIDs(); ++GraphID)
{
if (InLinker->HasLinkedSystem(GraphID))
{
continue;
}
UClass* Class = GlobalDependencyGraph.ClassFromGraphID(GraphID);
UMovieSceneEntitySystem* SystemCDO = Class ? Cast<UMovieSceneEntitySystem>(Class->GetDefaultObject()) : nullptr;
if (SystemCDO && !EnumHasAnyFlags(SystemCDO->SystemExclusionContext, LinkerContext))
{
SystemCDO->ConditionalLinkSystem(InLinker);
}
}
查看UMovieSceneEntitySystem的构造函数,GetGraphID将其存入了全局System图,由于System图中并没有删除System的方法,因此ID可以作为唯一索引这一系统的方法。感谢反射系统,所有System在引擎Init阶段均会构造其CDO,最终逐级调用至此构造函数注册进全局图中。
// UMovieSceneEntitySystem::UMovieSceneEntitySystem(...)
if (!GetClass()->HasAnyClassFlags(CLASS_Abstract))
{
GlobalDependencyGraphID = GlobalDependencyGraph.GetGraphID(GetClass());
}
else
{
GlobalDependencyGraphID = MAX_uint16;
}
回头看静态Link函数,需要通过IsRelevant判断才可以继续Link,我们在这里见到了ECS中Component的基类FComponentTypeID。
// UMovieSceneEntitySystem::IsRelevant(...)
if (RelevantComponent && InLinker->EntityManager.ContainsComponent(RelevantComponent))
{
return true;
}
return IsRelevantImpl(InLinker);
我们现在回到最初在ImportEntityImpl中打断点的位置,可以看到ComponentTypeID可以从FBuiltInComponentTypes中获取,而FBuiltInComponentTypes本身是一个包含了众多ComponentTypeID的结构,Get方法返回了全局唯一指针。
// UMovieScene3DTransformSection::ImportEntityImpl(...)
FBuiltInComponentTypes* BuiltInComponentTypes = FBuiltInComponentTypes::Get();
UMovieScenePropertyTrack* Track = GetTypedOuter<UMovieScenePropertyTrack>();
const TComponentTypeID<FGuid>& ObjectBinding = Track->IsA<UMovieScene3DTransformTrack>()
? BuiltInComponentTypes->SceneComponentBinding
: BuiltInComponentTypes->GenericObjectBinding;
FGuid ObjectBindingID = Params.GetObjectBindingID();
auto BaseBuilder = FEntityBuilder()
.Add(BuiltInComponentTypes->PropertyBinding, Track->GetPropertyBinding())
.AddConditional(ObjectBinding, ObjectBindingID, ObjectBindingID.IsValid());
在其构造函数中,通过ComponentRegistry对一个ComponentType进行注册。
// FBuiltInComponentTypes.h
TComponentTypeID<FGuid> GenericObjectBinding;
// -> FBuiltInComponentTypes::FBuiltInComponentTypes()
FComponentRegistry* ComponentRegistry = UMovieSceneEntitySystemLinker::GetComponents();
ComponentRegistry->NewComponentType(&GenericObjectBinding, TEXT(&#34;Generic Object Binding ID&#34;));
...
ComponentRegistry->Factories.DefineChildComponent(Tags.AdditiveBlend, Tags.AdditiveBlend);
在NewComponentType函数内,按照TComponentTypeID模板参数类型的大小与对齐值设定此Type的内容,存入自身的ComponentTypes数组中,基类FComponentTypeID中所保存的Value即是索引号。按照构造时提供的不同Flag堆叠特定类型的Mask,FComponentMask包含TBitArray,Bit位设1则代表具有此索引位置的Component。Tag类型以FComponentTypeID存储,由于不包含数据,存在ComponentTypes内的大小与对齐值设为0。
BuiltInComponentTypes的构造函数内也通过FEntityFactories定义了一系列的Component从父Entity到子Entity的传递关系,传递规则可在其头文件中找到。
BuiltInComponents结构只是其中一个包含了ComponentType定义的类,以相似的方法可以构造自定义的Component。
我们已经介绍了执行ECS过程需要的类型来源,接下来将结合一次大致的执行过程来介绍Entity的生成与使用。
3 执行
3.1 Entity的引入
回到执行入口,ProcessQueue函数中主要工作就是尝试将请求求值的范围拆解成更小的区间,CompiledDataManger在Compile的过程中将所有轨道上的段落区间、覆盖关系等拆解为FMovieSceneEvaluationFieldEntityTree数据结构保存在自身的ComponentField中,分隔帧等也同时一并生成,利用此即可进行拆分。
// FMovieSceneEntitySystemRunner::GameThread_ProcessQueue()
// Give the instance an opportunity to dissect the range into distinct evaluations
FSequenceInstance& Instance = InstanceRegistry->MutateInstance(Request.InstanceHandle);
Instance.DissectContext(Linker, Request.Context, Dissections);
...
DissectedUpdates.Add(...)
最初的Entity的加载必然在System对它执行之前,其过程在最初进入的生成阶段内
// FMovieSceneEntitySystemRunner::GameThread_SpawnPhase()
if (DissectedUpdates.Num() != 0)
{
const int32 PredicateOrder = DissectedUpdates[0].Order;
int32 Index = 0;
for (; Index < DissectedUpdates.Num() && DissectedUpdates[Index].Order == PredicateOrder; ++Index)
{
FDissectedUpdate Update = DissectedUpdates[Index];
if (ensure(InstanceRegistry->IsHandleValid(Update.InstanceHandle)))
{
FSequenceInstance& Instance = InstanceRegistry->MutateInstance(Update.InstanceHandle);
Instance.Update(Linker, Update.Context);
}
}
DissectedUpdates.RemoveAt(0, Index);
}
SequenceInstance调用其包含的SequenceUpdater进行Update。SequenceInstance内包含记录已加载Entity得Ledger,Updater在Update时会Cache被加载Entity的时间范围,被加载的Entity会在Ledger内包含ID引用,以避免重复的Entity加载。在确需更新时,从CompiledDataManager的树结构中根据当前请求时间取出需加载的Section,由Ledger去引入未缓存的Entity。
对于每个实现了IMovieSceneEntityProvider接口的Section子类,Ledger向其提供一个空的ImportedEntity作为根,Section向其添加Builder进行Component的组装,在来自子类Section的ImportImpl完成后,基类会将一系列通用Component组装进新的Entity中,标识其混合方式、由Section提供等。
// FEntityLedger::ImportEntity(...)
FImportedEntity ImportedEntity;
Provider->ImportEntity(Linker, Params, &ImportedEntity);
if (!ImportedEntity.IsEmpty())
{
if (UMovieSceneSection* Section = Cast<UMovieSceneSection>(EntityOwner))
{
Section->BuildDefaultComponents(Linker, Params, &ImportedEntity);
}
FMovieSceneEntityID NewEntityID = ImportedEntity.Manufacture(Params, &Linker->EntityManager);
Linker->EntityManager.ReplaceEntityID(EntityData.EntityID, NewEntityID);
}
对于用于组装Entity的Builder的使用,这里以UMovieSceneFloatSection::ImportEntityImpl(...)为例(使用的是包装一层的资产PropertyTrackEntityImportHelper)进行介绍。
// UMovieSceneFloatSection::ImportEntityImpl(...)
const FBuiltInComponentTypes* Components = FBuiltInComponentTypes::Get();
const FMovieSceneTracksComponentTypes* TracksComponents = FMovieSceneTracksComponentTypes::Get();
FPropertyTrackEntityImportHelper(TracksComponents->Float)
.Add(Components->FloatChannel[0], &FloatCurve)
.Commit(this, Params, OutImportedEntity);
UE以Tuple形式实现了这类Builder,在TPropertyTrackEntityImportHelperImpl类内见的其成员仅TEntityBuilder<...>。此处模板中已经包含了FAdd参数的原因是其构造函数要求一个ProprtyType的Tag。进入TEntityBuilder可见其成员仅TTuple自己,每次调用Add等方法,Builder以自己的Tuple加上传入参数构造一个新的Tuple(具体细节请细看TTuple实现),使得不同类型的Component均以FAdd及其子类存入此Tuple。
FAdd实现两个方法,AccumulateMask方法可以按自己Component所在的Index添加到Mask上,Apply方法将实际内容移动到Entity所处内存的Component存储位置处,而Apply方法可以在Builder的Initialize方法中发现,通过VisitTuple方法调用每个FAdd的Apply方法,以达到填充Entity内存数据的目的。
// template <typename... T, int... Indices>
// struct TEntityBuilderImpl<TIntegerSequence<int, Indices...>, T...> : IEntityBuilder
// 典型的Add函数
template <typename... T, int... Indices>
template<typename U, typename PayloadType>
TEntityBuilder<T..., TAdd<U> > TEntityBuilderImpl<TIntegerSequence<int, Indices...>, T...>::Add(TComponentTypeID<U> ComponentType, PayloadType&& InPayload)
{
return TEntityBuilder<T..., TAdd<U> >(MoveTemp(Payload.template Get<Indices>())..., TAdd<U>(ComponentType, Forward<PayloadType>(InPayload)), bAddMutualComponents );
}
// template<typename T> struct TAdd : FAdd
// TAdd<T>::Apply(...)
const FComponentHeader& Header = Allocation->GetComponentHeaderChecked(ComponentTypeID);
check(!Header.IsTag());
Header.ReadWriteLock.WriteLock();
T* ComponentPtr = static_cast<T*>(Header.GetValuePtr(ComponentOffset));
*ComponentPtr = MoveTemp(Payload.GetValue());
Payload.Reset();
Header.ReadWriteLock.WriteUnlock();
因此,Section可以按照自己的需求将需要处理的数据以Component形式添加到ImportedEntity上,在后续Entity的内存得到分配后将数据移动到新的内存位置。
回到Ledger调用点,可见ImportedEntity.Manufacture的函数调用传入了EntityManager,调用后产出了EntityID,由Section引入的Entity在此函数内获得其内存。
// FImportedEntity::Manufacture(...)
FComponentMask NewMask;
BaseBuilder.GenerateType(EntityManager, NewMask);
for (TInlineValue<IEntityBuilder>& Builder : Builders)
{
Builder->GenerateType(EntityManager, NewMask);
}
FEntityInfo NewEntity = EntityManager->AllocateEntity(NewMask);
BaseBuilder.Initialize(EntityManager, NewEntity);
for (TInlineValue<IEntityBuilder>& Builder : Builders)
{
Builder->Initialize(EntityManager, NewEntity);
}
对于EntityManager,首先了解下其各个成员的含义:
FEntityLocation: - 每个Entity一个。在EntityManager成员数组中的索引即为EntityID,记录关联内存位置Allocation,记录此Entity是内存位置的第几个
FEntityAllocation: - 每个内存位置一个。多个Entity如果具有相同的Component种类会被存在一起,每个Allocation会记录其内一共有多少Entity,以及最多可以装多少个Entity。
- 此外,创建Allocation分配的内存实际是sizeof(FEntityAllocation) + sizeof(ComponentData),Allocation内记录了首个Component的位置,分配时按照记录的大小与对齐值计算出每类Component总和的大小,各个Component以数组元素的形式存在其中,Allocation会记录自己包含了多少个Component。
- 新的Entity引入时,若Component相同则可以塞到已有的Allocation内部,因为每个新的Allocation实际上都会预留额外的空间,若没有分配满,则只需将保存数目+1,而不必每次都重新申请内存。
FEntityManager::AllocateEntity函数负责关联单个Entity的Location与Allocation。其内的GetOrCreateAllocationWithSlack函数会判断是否需要分配新的Entity,若需则由CreateEntityAllocation完成创建,而AddEntityToAllocation会将Allocation此时的偏移量记录到Location内,后续即根据此寻找Entity。
// FEntityManager::AllocateEntity(...)
// ID与新的Entity
const int32 NewEntityIndex = EntityLocations.Add(FEntityLocation{});
FMovieSceneEntityID NewEntityID = FMovieSceneEntityID::FromIndex(NewEntityIndex);
// 找到或创建位置
int32 AllocationIndex = GetOrCreateAllocationWithSlack(EntityComponentMask);
// Entity放到Allocation
int32 EntryIndexWithinAllocation = AddEntityToAllocation(AllocationIndex, NewEntityID);
// 互相关联
EntityLocations[NewEntityIndex].Set(AllocationIndex, EntryIndexWithinAllocation);
至此,由Section引入的首个Entity完成了其创建,Manager内标记了其ID、入口及一系列标志。完成Component的写入并一路回到Runner中,我们可以开始准备执行了。
3.2 System图构造及使用
已经提到了Component与System的注册时机,这里介绍GlobalDependencyGraph的大致用处。
如果有查看过GlobalDependencyGraph会发现Component与Class(UMovieSceneEntitySystem)都是FNode可以包含的类型,那Component是何时进入节点呢?以UMovieSceneGenericBoundObjectInstantiator系统为例,查看其构造函数:
// UMovieSceneGenericBoundObjectInstantiator::UMovieSceneGenericBoundObjectInstantiator(...)
FBuiltInComponentTypes* Components = FBuiltInComponentTypes::Get();
RelevantComponent = Components->GenericObjectBinding;
if (HasAnyFlags(RF_ClassDefaultObject))
{
DefineComponentProducer(GetClass(), Components->BoundObject);
}
通过调用基类DefineComponentProducer函数,GlobalDependencyGraph的GetGraphID作用于ComponentType上,使此Type被注册,不被各个系统依赖的ComponentType并不必要被注册进图中。Producer函数以有向边形式关联此系统与Component,类似的Consumer函数作相反的关联。GlobalDependencyGraph保留两个有向边列表,以便快速查询某个系统依赖的上游与下游节点。
完成全部系统的加载后,GlobalDependencyGraph中已经完全包含了所有System间的依赖关系。在随后Linker->LinkRelevantSystems()时,基类遍历整个GlobalDependencyGraphClass数组,此时若子类通过relevant判断,则执行Linker->LinkSystem。此时有两步重要工作要做:
// UMovieSceneEntitySystemLinker::LinkSystem(...)
// Linker的SystemGraph中添加此System节点。
SystemGraph.AddSystem(NewSystem);
// 将GlobalDependencyGraph中相互依赖System的配置加载进Linker.SystemGraph
NewSystem->Link(this);
随后link完成,执行函数的进入会触发图缓存的更新,System在UpdateCache()内被分配至各自阶段的执行数组并安排好执行顺序,在此函数内会执行DFS来检测System依赖是否构成环。
此时再进入FMovieSceneEntitySystemGraph::ExecutePhase,System已经按顺序装入列表,依次遍历调用其OnRun即可开始每个System的一次执行了!
// template<typename ArrayType>
// FMovieSceneEntitySystemGraph::ExecutePhase(...)
System->Run(Prerequisites ? *Prerequisites : NoPrerequisites, DownstreamTasks);
if (bStructureCanChange)
{
Linker->AutoLinkRelevantSystems();
}
3.3 System如何执行
在大规模内容保障一文中,关于System执行的几张图已经清晰展示了运行的流程:一次任务执行通过Component类型匹配Entity,运行自己的逻辑得出结果后写入目标位置;为了保证通用性,以属性轨道为例,求值与写入被分在不同System中完成。这里不重复把图片贴过来了,仅在代码上介绍一点他的实现……
System类型众多,在此只是介绍System与Entity的交互,而非具体某个系统的功能与实现。当然由于基类没有具体内容,我们仍以UMovieSceneGenericBoundObjectInstantiator系统为例,可以发现仅FEntityTaskBuilder相关的部分可能与Entity有交互。
EntityTaskBuilder也是以Tuple为底层的类,通过Read方法与Write方法创建继承自FComponentAccess的子类Accessor并加入自身Tuple中,Accessor基类保存ComponentTypeID用以得到数据。对于需要额外使用Tag匹配Entity的任务,通过Filter方法将任务转换为FilterTask,保有数个ComponentMask,用以在相应的Tag在Entity中一定有、可能有、一定无等情况下取出Entity,同时已经添加入Tuple的Component也会加入到Mask内,以便快速匹配。
// TEntityTaskComponents::FilterNone(...)
TFilteredEntityTask< T... > Filtered(*this);
Filtered.FilterNone(InComponentTypes);
实际的Builder构建出的类型在编写实际要执行的操作上并没有任何区别,紧随构造其后添加(Iterate/Dispatch)_(PerEntity/PerAllocation)方法,即可实现对已构造任务的执行,区别在于: Iterate前缀:单线程执行 Dispatch前缀:在可能的情况下通过GraphTask多线程执行 PerEntity后缀:负载传入的参数是每单个Entity的Component值的引用,例如float& PerAllocation后缀:传入参数包含EntityAllocation指针,及对应Component的TRead/TWrite等继承自TComponentPtr的访问类。访问类功能类似于数组,通过指针加偏移量即可获取到实际数据。
在TDefaultEntityTaskTraits的注释中可以看到关于各函数的示例写法,如果希望负载实现的参数不由Tuple打开,可以手动实例化以自定义负载为模板参数的此类,以使得参数整体传入而非分散传入(默认展开)。
struct FForEach_Expanded
* {
* void ForEachEntity(float, uint16, UObject*);
* void ForEachAllocation(const FEntityAllocation*, TRead<float>, TRead<uint16>, TRead<UObject*>);
* };
* struct FForEach_NoExpansion
* {
* void ForEachEntity(const TEntityPtr<const float, const uint16, const UObject*>&);
* void ForEachAllocation(const FEntityAllocation*, const TEntityTaskComponents<TRead<float>, TRead<uint16>, TRead<UObject*>>&);
* };
Builder构建完调用任一执行函数后,会创建出自定义负载的包装类,并在此类中调用自定义负载进行执行。
// 以Entity为例,Allocation使用不同的包装
// TEntityTaskBase::Run(...)
PreTask(&TaskImplInstance);
for (FEntityAllocation* Allocation : EntityManager->Iterate(&FilteredTask.GetFilter()))
{
Caller::ForEachEntityImpl(TaskImplInstance, Allocation, WriteContext, FilteredTask.GetComponents());
}
PostTask(&TaskImplInstance);
自定义负载类至少需要实现ForEachEntity或ForEachAllocation方法,以能够对每个Entity执行自定义操作。可以选择额外实现PreTask及PostTask方法,对应在自定义方法调用的前后执行。Caller执行时,从EntityManager取出Entity,解算各Component位置构造传入参数,随后执行调用。
多个Task组合成系统,各个系统间划分运行阶段,组成依赖关系图,由LInker按需链接依次调用,就完成了一次内容的更新。
3.4 实际执行
尽管知道了System时如何执行的,但对于一个具体的轨道,例如把Actor平移一段距离,操作的Entity事实上并不是Import自Section的Entity,而是由其通过EntityFactory构造出的ChildEntity。因此要搞清运行机理,需了解Component与System的交互关系,也就是本文略去的这部分,哈哈。
4 后记
大规模内容的性能保障:虚幻引擎4.26中的Sequencer - Unreal Engine
本文浮于表面,仔细阅读官方文档可以了解核心理念,写本文的原因是纪念下上班的惊恐两周。如果再有空,可能会再摘点别的出来。 |
|