Fog of War(FOW) Tutorial --- UE4

最近正好有实现战争迷雾的需求,在forum里找到了一篇教程
Fog of War tutorial by Isvulfe

Isvulfe的思路是这样的:
1、需要一张动态生成的贴图。这张贴图将会投影到游戏场景里,因此,未被探索的部分对应的像素黑色的,探索部分是白色的。
2、利用Post-process材质将动态贴图投影到场景中。一个纹素将会对应到游戏世界里的一个面积。材质会用到“AbsoluteWorldPosition”节点作为动态贴图的uv坐标,将贴图投影到游戏世界的xy平面上。贴图的颜色与场景中的颜色相乘得到输入的颜色。场景的颜色需通过SceneTexture:SceneColor获得。
Post-Process的顺序是很重要的,作者提及这个效果的应用要在Mapping of Tones前,否则会产生奇怪的效果。
3、动态贴图生成用C++实现,在蓝图中完成对Post-process材质的使用,需要用到蓝图节点“SetTextureParameterValue”。
4、动态纹理生成时,利用高斯blur使得黑白区域的过渡不会生硬。
5、FOW 不是每帧都计算,而是每0.25s,这样可以减少计算开销。
6、由于不是逐帧计算FOW,所以为了不会有明显的跳变感,将上一次计算的贴图和当前计算的贴图混合。
7、FOW的计算放在单独的线程中。

相关联的概念有:
FOV(filed of vision),Blur。不同的算法可以实现不同的效果,效率也各不相同。
可以参考:
http://www.roguebasin.com/index.php?title=FOV


接下来是具体实现:
首先提一句,因为看代码的时候,你可能会想问为什么要给一堆TArray变量加UPROPERTY(),明明这些变量有的都没暴露到Editor中,这是因为UE的内存管理,TArray必须加UPROPERTY(),否则会导致内存管理出错。

FogOfWarManager.h

UCLASS() 
class RPGTEST_API AFogOfWarManager : public AActor
{
    GENERATED_BODY()    
    AFogOfWarManager(const FObjectInitializer & FOI);
    virtual ~AFogOfWarManager();
    virtual void BeginPlay() override;          
    virtual void Tick(float DeltaSeconds) override;
public: 
    //Triggers a update in the blueprint
    // 在蓝图中实现和调用,触发一次纹理的更新
//感觉用BlueprintImplementableEvent就行,因为c++版并没有写实现
    UFUNCTION(BlueprintNativeEvent)
    void OnFowTextureUpdated(UTexture2D* currentTexture, UTexture2D* lastTexture);
    
    //Register an actor to influence the FOW-texture
    // 注册一个会影响FOW纹理的角色
    void RegisterFowActor(AActor* Actor);

    //Stolen from https://wiki.unrealengine.com/Dynamic_Textures
      //从unreal wiki上偷来的动态生成纹理的算法,好像4.17版本后,引擎自带了UTexture2D::UpdateTextureRegions可以不用在自己写
    void UpdateTextureRegions(
        UTexture2D* Texture, 
        int32 MipIndex, 
        uint32 NumRegions, 
        FUpdateTextureRegion2D* Regions, 
        uint32 SrcPitch, 
        uint32 SrcBpp, 
        uint8* SrcData, 
        bool bFreeData);

    //How far will an actor be able to see
    //CONSIDER: Place it on the actors to allow for individual sight-radius
// 一个Actor的可视范围,可以考虑将这个属性放到Actor中,这样就可以配置不同的可视范围
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = FogOfWar)
    float SightRange = 9.0f;

    //The number of samples per 100 unreal units
  //每100个单位,即1米需要几个采样
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = FogOfWar)
    float SamplesPerMeter = 2.0f;

    //If the last texture blending is done
//是否完成了与上一个纹理的混合,因为混合不是在主线程中完成的
    UPROPERTY(BlueprintReadWrite)
    bool bIsDoneBlending;

    //Should we blur? It takes up quite a lot of CPU time...
//是否要用Blur,使得纹理边缘不那么生硬,需要较大的CPU开销
    UPROPERTY(EditAnywhere)
    bool bIsBlurEnabled = true;

    //The size of our textures
//动态纹理的大小,1024其实蛮大了,需要大量计算,可以根据需要减小为512、256、128
    uint32 TextureSize = 1024;

    //Array containing what parts of the map we've unveiled.
//数组存储地图中哪些部分被探索了
    UPROPERTY()
    TArray<bool> UnfoggedData;

    //Temp array for horizontal blur pass
//水平方向blur后的暂时纹理副本
    UPROPERTY()
    TArray<uint8> HorizontalBlurData;

    //Our texture data (result of vertical blur pass)
//blur后的最终产出纹理
    UPROPERTY()
    TArray<FColor> TextureData;

    //Our texture data from the last frame
// 上一次计算的纹理
    UPROPERTY()
    TArray<FColor> LastFrameTextureData;

    //Check to see if we have a new FOW-texture.
//是否生成了新的FOW纹理,同样是因为纹理的产生是在另一个线程里
    bool bHasFOWTextureUpdate = false;

    //Blur size
//blur算法的kernel大小
    uint8 blurKernelSize = 15;

    //Blur kernel
//blur算法的kernel
    UPROPERTY()
    TArray<float> blurKernel;

    //Store the actors that will be unveiling the FOW-texture.
//保存会影响 FOW纹理的Actor 
    UPROPERTY()
    TArray<AActor*> FowActors;

    //DEBUG: Time it took to update the fow texture
//用来记录Update FOW纹理需要的时间
    float fowUpdateTime = 0;

    //Getter for the working thread
// 供纹理生成线程调用的Getter函数
    bool GetIsBlurEnabled();
        
private:    
//好像都没在cpp中实现,这里作者也没写备注
    void UpdateFowTexture();
    
    //Triggers the start of a new FOW-texture-update
//触发开始新的FOW纹理的更新
    void StartFOWTextureUpdate();   
    
    //Our dynamically updated texture
//我们的动态更新纹理
    UPROPERTY()
    UTexture2D* FOWTexture;

    //Texture from last update. We blend between the two to do a smooth unveiling of newly discovered areas.
//上一次更新的FOW纹理,用来blend
    UPROPERTY()
    UTexture2D* LastFOWTexture; 
    
    //Texture regions   
    FUpdateTextureRegion2D* textureRegions; 

    //Our fowupdatethread   
// 我们辛勤工作的fow计算线程   
    AFogOfWarWorker* FowThread;
};

FogOfWarManager.cpp

AFogOfWarManager::AFogOfWarManager(const FObjectInitializer &FOI) : Super(FOI) {    
    PrimaryActorTick.bCanEverTick = true;

// 我们用来动态生成纹理的结构体,这里用了new,我看析构函数里也没delete。。。。推荐还是用TSharePtr之类来进行内存管理,虽然我还没怎么用过
    textureRegions = new FUpdateTextureRegion2D(0, 0, 0, 0, TextureSize, TextureSize);      
    
    //15 Gaussian samples. Sigma is 2.0.
    //CONSIDER: Calculate the kernel instead, more flexibility...
//高斯采样的kernel,还是用计算来的方便,这里是直接写了
    blurKernel.Init(0.0f, blurKernelSize);
    blurKernel[0] = 0.000489f;
    blurKernel[1] = 0.002403f;
    blurKernel[2] = 0.009246f;
    blurKernel[3] = 0.02784f;
    blurKernel[4] = 0.065602f;
    blurKernel[5] = 0.120999f;
    blurKernel[6] = 0.174697f;
    blurKernel[7] = 0.197448f;
    blurKernel[8] = 0.174697f;
    blurKernel[9] = 0.120999f;
    blurKernel[10] = 0.065602f;
    blurKernel[11] = 0.02784f;
    blurKernel[12] = 0.009246f;
    blurKernel[13] = 0.002403f;
    blurKernel[14] = 0.000489f;
}

AFogOfWarManager::~AFogOfWarManager() {
//关闭线程
    if (FowThread) {        
        FowThread->ShutDown();
    }
}

void AFogOfWarManager::BeginPlay() {    
    Super::BeginPlay();
    bIsDoneBlending = true;
//为FOW纹理计算做初始化
    AFogOfWarManager::StartFOWTextureUpdate();
}

void AFogOfWarManager::Tick(float DeltaSeconds) {
    Super::Tick(DeltaSeconds);  
//判断工作线程是否计算完了FOW纹理并完成了Blend
//如果完成了,则更新data到纹理上,并触发蓝图中的OnFowTextureUpdated
    if (FOWTexture && LastFOWTexture && bHasFOWTextureUpdate && bIsDoneBlending) {      
        LastFOWTexture->UpdateResource();
        UpdateTextureRegions(LastFOWTexture, (int32)0, (uint32)1, textureRegions, (uint32)(4 * TextureSize), (uint32)4, (uint8*)LastFrameTextureData.GetData(), false);     
        FOWTexture->UpdateResource();
        UpdateTextureRegions(FOWTexture, (int32)0, (uint32)1, textureRegions, (uint32)(4 * TextureSize), (uint32)4, (uint8*)TextureData.GetData(), false);      
        bHasFOWTextureUpdate = false;
        bIsDoneBlending = false;
        //Trigger the blueprint update
        OnFowTextureUpdated(FOWTexture, LastFOWTexture);        
    }
}

//初始化纹理,数组,工作线程
void AFogOfWarManager::StartFOWTextureUpdate() {    
    if (!FOWTexture) {
        FOWTexture = UTexture2D::CreateTransient(TextureSize, TextureSize);
        LastFOWTexture = UTexture2D::CreateTransient(TextureSize, TextureSize);
        int arraySize = TextureSize * TextureSize;
        TextureData.Init(FColor(0, 0, 0, 255), arraySize);
        LastFrameTextureData.Init(FColor(0, 0, 0, 255), arraySize);
        HorizontalBlurData.Init(0, arraySize);
        UnfoggedData.Init(false, arraySize);    
//应该也要delete吧   
        FowThread = new AFogOfWarWorker(this);
    }   
}

void AFogOfWarManager::OnFowTextureUpdated_Implementation(UTexture2D* currentTexture, UTexture2D* lastTexture) {
    //Handle in blueprint
}

//添加actor进数组
void AFogOfWarManager::RegisterFowActor(AActor* Actor) {
    FowActors.Add(Actor);
}
//Getter函数供工作线程使用
bool AFogOfWarManager::GetIsBlurEnabled() {
    return bIsBlurEnabled;
}

//4.17以后的版本提供了,不用自己写啦!!虽然也没看懂,知道是将data数组中的数据写进Texture中就好了。
void AFogOfWarManager::UpdateTextureRegions(UTexture2D* Texture, int32 MipIndex, uint32 NumRegions, FUpdateTextureRegion2D* Regions, uint32 SrcPitch, uint32 SrcBpp, uint8* SrcData, bool bFreeData)
{
    if (Texture && Texture->Resource)
    {
        struct FUpdateTextureRegionsData
        {
            FTexture2DResource* Texture2DResource;
            int32 MipIndex;
            uint32 NumRegions;
            FUpdateTextureRegion2D* Regions;
            uint32 SrcPitch;
            uint32 SrcBpp;
            uint8* SrcData;
        };

        FUpdateTextureRegionsData* RegionData = new FUpdateTextureRegionsData;

        RegionData->Texture2DResource = (FTexture2DResource*)Texture->Resource;
        RegionData->MipIndex = MipIndex;
        RegionData->NumRegions = NumRegions;
        RegionData->Regions = Regions;
        RegionData->SrcPitch = SrcPitch;
        RegionData->SrcBpp = SrcBpp;
        RegionData->SrcData = SrcData;

        ENQUEUE_UNIQUE_RENDER_COMMAND_TWOPARAMETER(
            UpdateTextureRegionsData,
            FUpdateTextureRegionsData*, RegionData, RegionData,
            bool, bFreeData, bFreeData,
            {
                for (uint32 RegionIndex = 0; RegionIndex < RegionData->NumRegions; ++RegionIndex)
                {
                    int32 CurrentFirstMip = RegionData->Texture2DResource->GetCurrentFirstMip();
                    if (RegionData->MipIndex >= CurrentFirstMip)
                    {
                        RHIUpdateTexture2D(
                            RegionData->Texture2DResource->GetTexture2DRHI(),
                            RegionData->MipIndex - CurrentFirstMip,
                            RegionData->Regions[RegionIndex],
                            RegionData->SrcPitch,
                            RegionData->SrcData
                            + RegionData->Regions[RegionIndex].SrcY * RegionData->SrcPitch
                            + RegionData->Regions[RegionIndex].SrcX * RegionData->SrcBpp
                            );
                    }
                }
                if (bFreeData)
                {
                    FMemory::Free(RegionData->Regions);
                    FMemory::Free(RegionData->SrcData);
                }
                delete RegionData;
            });
    }
}

来看看我们的工作线程:
FogOfWarWorker.h

/**
 * Worker thread for updating the fog of war data.
 */
class AFogOfWarManager;

class AFogOfWarWorker : public FRunnable
{   
    //Thread to run the FRunnable on
    FRunnableThread* Thread;    

    //Pointer to our manager
    AFogOfWarManager* Manager;

    //Thread safe counter 
    FThreadSafeCounter StopTaskCounter; 

public:
    AFogOfWarWorker();
    AFogOfWarWorker(AFogOfWarManager* manager);
    virtual ~AFogOfWarWorker();

    //FRunnable interface
    virtual bool Init();
    virtual uint32 Run();
    virtual void Stop();        

    //Method to perform work
    void UpdateFowTexture();

    bool bShouldUpdate = false;

    void ShutDown();
};

AFogOfWarWorker::AFogOfWarWorker() {}

AFogOfWarWorker::AFogOfWarWorker(AFogOfWarManager* manager){
    Manager = manager;
//创建线程
    Thread = FRunnableThread::Create(this, TEXT("AFogOfWarWorker"), 0U, TPri_BelowNormal);
}

AFogOfWarWorker::~AFogOfWarWorker() {
//销毁线程
    delete Thread;
    Thread = NULL; 
}

void AFogOfWarWorker::ShutDown() {
    Stop();
    Thread->WaitForCompletion();    
}

bool AFogOfWarWorker::Init() {
    if (Manager) {
        Manager->GetWorld()->GetFirstPlayerController()->ClientMessage("Fog of War worker thread started");
        return true;
    }
    return false;
}

uint32 AFogOfWarWorker::Run() {
//盲猜这个时间是用来等manager的初始化
    FPlatformProcess::Sleep(0.03f);
    while (StopTaskCounter.GetValue() == 0) {
        float time;
        if (Manager && Manager->GetWorld()) {
            time = Manager->GetWorld()->TimeSeconds;
        }
        if (!Manager->bHasFOWTextureUpdate) {
            UpdateFowTexture();
            if (Manager && Manager->GetWorld()) {
                Manager->fowUpdateTime = Manager->GetWorld()->TimeSince(time);
            }
        }
        FPlatformProcess::Sleep(0.1f);
    }
    return 0;
}
// 功能的核心
void AFogOfWarWorker::UpdateFowTexture() {

    Manager->LastFrameTextureData = TArray<FColor>(Manager->TextureData);
    uint32 halfTextureSize = Manager->TextureSize / 2;
    int signedSize = (int)Manager->TextureSize; //For convenience....
    TSet<FVector2D> currentlyInSight;
    TSet<FVector2D> texelsToBlur;
    int sightTexels = Manager->SightRange * Manager->SamplesPerMeter;
    float dividend = 100.0f / Manager->SamplesPerMeter; 

//逐个Actor进行循环
    for (auto Itr(Manager->FowActors.CreateIterator()); Itr; Itr++) {
        //Find actor position
        if(!*Itr) return;       
        FVector position = (*Itr)->GetActorLocation();      
        
        //We divide by 100.0 because 1 texel equals 1 meter of visibility-data.
              //  将actor的世界坐标转移到纹理坐标,世界的xy平面(0,0)-->纹理中心(halfTextureSize ,halfTextureSize)
        int posX = (int)(position.X / dividend) + halfTextureSize;
        int posY = (int)(position.Y / dividend) + halfTextureSize;
        float integerX, integerY;

        FVector2D fractions = FVector2D(modf(position.X / 50.0f, &integerX), modf(position.Y / 50.0f, &integerY));
        FVector2D textureSpacePos = FVector2D(posX, posY);
        int size = (int)Manager->TextureSize;
// Collision Query
        FCollisionQueryParams queryParams(FName(TEXT("FOW trace")), false, (*Itr));
        int halfKernelSize = (Manager->blurKernelSize - 1) / 2;
        
        //Store the positions we want to blur
//需要blur的纹素
        for (int y = posY - sightTexels - halfKernelSize; y <= posY + sightTexels + halfKernelSize; y++) {
            for (int x = posX - sightTexels - halfKernelSize; x <= posX + sightTexels + halfKernelSize; x++) {
                if (x > 0 && x < size && y > 0 && y < size) {
                    texelsToBlur.Add(FIntPoint(x, y));
                }
            }
        }
         

// FOV & Blur
                
    //FOV
        //Unveil the positions our actors are currently looking at
        for (int y = posY - sightTexels; y <= posY + sightTexels; y++) {
            for (int x = posX - sightTexels; x <= posX + sightTexels; x++) {
                //Kernel for radial sight
                if (x > 0 && x < size && y > 0 && y < size) {
                    FVector2D currentTextureSpacePos = FVector2D(x, y);
                    int length = (int)(textureSpacePos - currentTextureSpacePos).Size();
                    if (length <= sightTexels) {
                        FVector currentWorldSpacePos = FVector(
                            ((x - (int)halfTextureSize)) * dividend,
                            ((y - (int)halfTextureSize)) * dividend,
                            position.Z);

                        //CONSIDER: This is NOT the most efficient way to do conditional unfogging. With long view distances and/or a lot of actors affecting the FOW-data
                        //it would be preferrable to not trace against all the boundary points and internal texels/positions of the circle, but create and cache "rasterizations" of
                        //viewing circles (using Bresenham's midpoint circle algorithm) for the needed sightranges, shift the circles to the actor's location
                        //and just trace against the boundaries. 
                        //We would then use Manager->GetWorld()->LineTraceSingle() and find the first collision texel. Having found the nearest collision
                        //for every ray we would unveil all the points between the collision and origo using Bresenham's Line-drawing algorithm.
                        //However, the tracing doesn't seem like it takes much time at all (~0.02ms with four actors tracing circles of 18 texels each),
                        //it's the blurring that chews CPU..
                        if (!Manager->GetWorld()->LineTraceTest(position, currentWorldSpacePos, ECC_WorldStatic, queryParams)) {                            
                            //Unveil the positions we are currently seeing
                            Manager->UnfoggedData[x + y * Manager->TextureSize] = true;
                            //Store the positions we are currently seeing.
                            currentlyInSight.Add(FVector2D(x, y));
                        }
                    }
                }
            }
        }
    }   

//Blur
    if (Manager->GetIsBlurEnabled()) {
        //Horizontal blur pass
        int offset = floorf(Manager->blurKernelSize / 2.0f);         
        for (auto Itr(texelsToBlur.CreateIterator()); Itr; ++Itr) {
            int x = (Itr)->IntPoint().X;
            int y = (Itr)->IntPoint().Y;
            float sum = 0;
            for (int i = 0; i < Manager->blurKernelSize; i++) {
                int shiftedIndex = i - offset;
                if (x + shiftedIndex >= 0 && x + shiftedIndex <= signedSize - 1) {
                    if (Manager->UnfoggedData[x + shiftedIndex + (y * signedSize)]) {
                        //If we are currently looking at a position, unveil it completely
                        if (currentlyInSight.Contains(FVector2D(x + shiftedIndex, y))) {
                            sum += (Manager->blurKernel[i] * 255);
                        }
                        //If this is a previously discovered position that we're not currently looking at, put it into a "shroud of darkness".                          
                        else {
                            sum += (Manager->blurKernel[i] * 100);
                        }
                    }
                }
            }
            Manager->HorizontalBlurData[x + y * signedSize] = (uint8)sum;
        }
        

        //Vertical blur pass
        for (auto Itr(texelsToBlur.CreateIterator()); Itr; ++Itr) {
            int x = (Itr)->IntPoint().X;
            int y = (Itr)->IntPoint().Y;
            float sum = 0;
            for (int i = 0; i < Manager->blurKernelSize; i++) {
                int shiftedIndex = i - offset;
                if (y + shiftedIndex >= 0 && y + shiftedIndex <= signedSize - 1) {
                    sum += (Manager->blurKernel[i] * Manager->HorizontalBlurData[x + (y + shiftedIndex) * signedSize]);
                }
            }
            Manager->TextureData[x + y * signedSize] = FColor((uint8)sum, (uint8)sum, (uint8)sum, 255);
        }       
    }
    else {
        for (int y = 0; y < signedSize; y++) {
            for (int x = 0; x < signedSize; x++) {

                if (Manager->UnfoggedData[x + (y * signedSize)]) {
                    if (currentlyInSight.Contains(FVector2D(x, y))) {
                        Manager->TextureData[x + y * signedSize] = FColor((uint8)255, (uint8)255, (uint8)255, 255);
                    }
                    else {
                        Manager->TextureData[x + y * signedSize] = FColor((uint8)100, (uint8)100, (uint8)100, 255);
                    }
                }
            }
        }
    }
    Manager->bHasFOWTextureUpdate = true;
}

void AFogOfWarWorker::Stop() {
    StopTaskCounter.Increment();
}

还有部分蓝图实现,请点击链接查看原文,有大图。
Fog of War tutorial by Isvulfe
第10页Juancahf对代码做了修改并post出了他的工作;第11页有他的ue工程(UE 4.21版本)供下载。

正如原作者所说的一样,这不是一种最好的实现战争迷雾的方式,仅仅是提供了一种实现的思路。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容