TextureStreamingJob 崩溃分析一则

TextureStreamingJob 崩溃分析一则

这是第163篇UWA技术知识分享的推送。今天我们为大家精选了一个和开发、优化相关的深度话题,建议阅读时间10分钟,认真读完必有收获。

UWA 问答社区:answer.uwa4d.com
UWA QQ群2:793972859(原群已满员)


TextureStreaming

Q:TextureStreamingJob在Android、iOS、PC均发生崩溃。

崩溃的调用栈如下:

Unity.exe!TextureStreamingJob(struct TextureStreamingJobData *)
Unity.exe!JobQueue::Exec(struct JobInfo *,__int64,int)
Unity.exe!JobQueue::Steal(class JobGroup *,struct JobInfo *,__int64,int,bool)
Unity.exe!JobQueue::ExecuteJobFromQueue(void)
Unity.exe!JobQueue::ProcessJobs(void *)
Unity.exe!JobQueue::WorkLoop(void *)
Unity.exe!Thread::RunThreadWrapper(void *)
kernel32.dll!BaseThreadInitThunk()
ntdll.dll!RtlUserThreadStart()

或者iOS如下:

1 app TextureStreamingJob (FloatConversion.h:127)
2 app Exec (JobQueue.cpp:412)
3 app Steal (JobQueue.cpp:673)
4 app ExecuteJobFromQueue (JobQueue.cpp:832)
5 app ProcessJobs (JobQueue.cpp:890)
6 app WorkLoop (JobQueue.cpp:976)
7 app RunThreadWrapper (Thread.cpp:76)

Android也类似。

A:这里讲下已经提交Bug Report的一则。根据调用栈定位到问题现场,分析整理上下文后,伪代码大致如下:

void TextureStreamingJob(TexutreStreamingJobData* jobdata)
{
    // 以上省略
    auto& sharedData = jobdata->sharedData;
    auto smallestMip = jobdata->smallestMip;
    auto largestMip = jobdata->largestMip;
    count = sharedData.textures.size();
    
    auto& currBatchDesiredMipLevels = jobdata->results->desiredMipLevels[jobdata->batchIndex];
    
    if (count)
    {
        for (int i = 0; i < count; i++)
        {
            int8_t mipLevel = -1;
            
            if (sharedData.textures[i].unknownFloatValue >= 0.0)
                mipLevel = sharedData.textures[i].mipLevel;
            
            if (mip < 0)
                mipLevel = -1;
            
            if (mip >= smallestMip)
                mipLevel = smallestMip;
            
            if (mip <= largestMip)
                mipLevel = largestMip;
            
            currBatchDesiredMipLevels[i].mipLevel = mipLevel;    // << 此处崩溃
            currBatchDesiredMipLevels[i].unknownField = CONST_VALUE;
        }
    }
    
    // 以下省略...
}

崩溃发生在currBatchDesiredMipLevels[i].mipLevel = mipLevel;这一句。

根据分析,TextureStreamingManager会依照UnityEngine.QualitySettings中的streamingMipmapsRenderersPerFrame参数,对当前任务分组。

QualitySettings.streamingMipmapsRenderersPerFrame

举例来说,streamingMipmapsRenderersPerFrame = 2;

(1)如果场景中有2个streaming对象,只会有一组任务(后文我们称之为batchCount,索引称之为batchIndex),即batchCount = 1;
(2)如果存在3个streaming对象,batchCount = 2,第一组任务个数为2,第二组为1;
(3)如果存在4个streaming对象,batchCount = 2,第一组任务个数为2,第二组为2;
(4)如果存在5个streaming对象,batchCount = 3,第一组任务个数为2,第二组为2,第三组为1;

以此类推。

此处所说的第一组任务、第二组任务本文称之为batch。

jobdata->results->desiredMipLevels是一个数组,按batch存放。

(1)jobdata->results->desiredMipLevels[

由此可见 jobdata->results->desiredMipLevels.size() 应当等于batchCount。

// 类型名是瞎猜的,请意会 :(
struct TextureMipLevelInfo;
// 引擎中有自己的数据结构dynamic_array, 此处用std::vector表示涵义更加直观
std::vector<std::vector<TextureMipLevelInfo>> desiredMipLevels;   

根据崩溃现场,崩溃发生时 jobdata->results->desiredMipLevels[batchIndex]为NULL。于是进一步排查desiredMipLevels的size()和capacity()。其中size为1,capacity为8。

jobdata->results->desiredMipLevels1 此时jobdata->results->desiredMipLevels.size() == 1

发生了索引越界,于是需要分析batchIndex的来源。

一番调查后了解到,batchIndex来自于TextureStreamingManager::Update()

TextureStreamingManager::Update()
{
    // 以上省略
    if (this->jobBatchIndex > this->results->batchCount)
    {
        this->jobBatchIndex = 0;
    }
    // 省略
        TextureStreamingManager::InitJobData(/*省略参数...*/)    // 初始化 this->jobData
        ScheduleJobInternal(this->jobFence, &TextureStreamingJob, this->jobData, 0);
    // 省略
}

由此可以推断,在任务ScheduleJobInternal的时候,状态应该是正确的,如果当时jobBatchIndex = 1并且this->results->batchCount = 1,this->jobBatchIndex 应该会归零。

因此可以推断ScheduleJobInternal之后,TextureStreamingJob执行之前,任务数据发生了改变。

batchIndex任务初始化后不会修改,如果状态发生改变,想必是 this->results->batchCount 发生了改变。

什么样的操作可以影响batchCount呢,自然就想到了:streamingMipmapsRenderersPerFrame参数以及需要参与streaming的贴图数量。

有了猜测,那就需要进一步验证,重现需要满足以下的条件:

(1)每一帧TextureStreamingJob的任务数据被初始化之后,任务被实际执行之前,batchCount缩小;
(2)任务初始化时的batchIndex >= 缩小后的batchCount。

经过一番尝试,选用了一种最稳妥的方案:

(1)通过钩子让TextureStreamingJob的执行时机可控(每帧结束时);
(2)通过设置两个Quality切换RenderersPerFrame
(3)一个QualitySettings设置较小的Renderers Per Frame,另一个设置较大的数值;
(4)场景中放一些参与TextureStreaming的Renderer使batchCount刚好达到2;
(5)添加一个按钮,点击按钮时切换到Renderers Per Frame参数较大的QualitySettings;

点击按钮这帧结束时,TextureStreamingJob被执行——Crash!

请输入图片描述

关于问题的一些补充:

(1)虽然用于复现问题的工程通过修改Renderers Per Frame触发,但是不修改Renderers Per Frame也是可以触发的。
(2)除了崩溃在TextureStreamingJob开始的地方,在函数的后面也有访问 desiredMipLevels,因此也会崩在地址更靠后的地方。
(3)TextureStreamingManager::InitJobData中sharedData有做引用计数加一,并且用到的地方都会Unshare()因此不容易产生问题,而results没有,results虽然调用了Unshare(),引用计数为1,实际上JobSystem的线程和主线程访问和修改了同一份TextureStreamingResults,由此产生了Bug。

目前这个问题已经上报给Unity,等待官方修复,经测试存在于目前所有发行版本,2018.4.1,2019.1。

TextureStreaming可以有效减少内存,但是用下来Bug不少,已知还有一处崩溃。TextureStreaming目前与QualitySettings.masterTextureLimit设置冲突。特定情况下设置1/2会导致实际变成1/4(生效了两遍)。开关textureStreamingActive也会产生一些非预期的现象发生。如果项目开发接近后期,不建议此时接入。

感谢题主庄沁@UWA问答社区提供了回答,欢迎大家转至社区交流:
https://answer.uwa4d.com/question/5cf53a80d275113770982815

今天的分享就到这里。当然,生有涯而知无涯。在漫漫的开发周期中,您看到的这些问题也许都只是冰山一角,我们早已在UWA问答网站上准备了更多的技术话题等你一起来探索和分享。欢迎热爱进步的你加入,也许你的方法恰能解别人的燃眉之急;而他山之“石”,也能攻你之“玉”。

官网:www.uwa4d.com
官方技术博客:blog.uwa4d.com
官方问答社区:answer.uwa4d.com
UWA学堂:edu.uwa4d.com
官方技术QQ群:793972859(原群已满员)

封面图:Unity TextureOps
Unity的基本图像处理插件。

  • Questions about Texture Streaming Mipmap 发表在 2020年06月18日 回复

    [...]Regarding your second question, this is difficult to answer, and it has not yet been seen that it is used in online projects. There is an article here, which is recommended for you and the team who ar[...]