【杨宗宝】Cocos Creator 3.x Shader预编译,流畅游戏的关键

前言

"为什么我的游戏启动时该加载的都加载了,运行的时候还是一卡一卡的?"

在正常的游戏开发流程中,都会在进入游戏主要玩法之前,前置一部分预处理的操作,比如:资源加载,数据解析,对象池创建等等一些列操作来尽可能的保证游戏在正常运行时的流畅度。当然在Cocos Creator中,一部分操作引擎已经帮我们封装好了接口,那么还有一部分操作就需要我们基于现有的需求自己封装。

今天宗宝和大家分享一下**《针对Cocos Creator3.x H5平台的shader预编译》**可行性方案研究;优化一个你可能意想不到的性能点。

正文(H5平台)

Cocos Shader

Cocos Shader
官方给出的描述是这样的

Cocos Creator 中的着色器(Cocos Shader ,文件扩展名为 *.effect),
是一种基于 YAML 和 GLSL 的单源码嵌入式领域特定
语言(single-source embedded domain-specific language),
YAML 部分声明流程控制清单,GLSL 部分声明实际的 Shader 片段,
这两部分内容相互补充,共同构成了一个完整的渲染流程描述。

宗宝理解为Cocos Shader是Cocos引擎内置的,便于多平台shader编译的,大多数人都能接受的一种shader编写方案。详细的内容可以去官网了解

1.shader 创建

宗宝先献上引擎内的相关代码:

enging/cocos/render-scene/core/program-lib.ts 
public getGFXShader (device: Device, name: string, defines: MacroRecord, pipeline: PipelineRuntime, key?: string): Shader {
...
}

shader创建可以分为两部分

  • 材质文件加载成功后
  • 场景环境变化后(这个后边会提到)

此处的创建,只会创建一个shader对象,处理好对应平台进行渲染时所需要的数据结构和数据体,而真正的耗时点并不是在这里

2.shader 编译

来到重点部分了,宗宝尽量写的详细点;

1.Cocos Creator3.x 中H5平台的shader编译源码

在引擎中,h5平台shader编译的相关代码:咱们以webgl2为例

engin/cocos/gfx/webgl2/webgl2-commands.ts
export function WebGL2CmdFuncCreateShader(device: WebGL2Device, gpuShader: IWebGL2GPUShader): void {
  ...
}

在上边的提到的代码中,我们可以看到webgl2 相关shader一些操作逻辑

3.shader 编译时机

我们找见了相关平台shader编译的逻辑,我们顺着这个线索网上推,我们会找见对应webgl-shader.ts,webgl2-shader.ts等与平台对应的shader类,我们还是以webgl2为例

engin/cocos/gfx/webgl2/webgl2-shader.ts

export class WebGL2Shader extends Shader {
    get gpuShader(): IWebGL2GPUShader {
        if (this._gpuShader!.glProgram === null) {
            WebGL2CmdFuncCreateShader(WebGL2DeviceManager.instance, this._gpuShader!);
        }
        return this._gpuShader!;
    }

    private _gpuShader: IWebGL2GPUShader | null = null;

    public initialize(info: Readonly<ShaderInfo>): void {
        this._name = info.name;
        this._stages = info.stages;
        this._attributes = info.attributes;
        this._blocks = info.blocks;
        this._samplers = info.samplers;

        this._gpuShader = {
            name: info.name,
            blocks: info.blocks.slice(),
            samplerTextures: info.samplerTextures.slice(),
            subpassInputs: info.subpassInputs.slice(),

            gpuStages: new Array<IWebGL2GPUShaderStage>(info.stages.length),
            glProgram: null,
            glInputs: [],
            glUniforms: [],
            glBlocks: [],
            glSamplerTextures: [],
        };

        for (let i = 0; i < info.stages.length; ++i) {
            const stage = info.stages[i];
            this._gpuShader.gpuStages[i] = {
                type: stage.stage,
                source: stage.source,
                glShader: null,
            };
        }
    }

    public destroy(): void {
        if (this._gpuShader) {
            WebGL2CmdFuncDestroyShader(WebGL2DeviceManager.instance, this._gpuShader);
            this._gpuShader = null;
        }
    }
}

上边是webgl2-shader.ts的完整代码。

我们在上边的内容中调到了2.shader 创建,那么此时就形成了一个闭环,结合program-lib.ts 中的代码和webgl2-shader.ts 我们的shader 创建是只会调用initialize函数。而WebGL2CmdFuncCreateShader函数,也就是编译并不会调用,是在gpuShader调用的时候进行的。我们再往上一路反推其实就很容易发现shader的编译实际发生在引用他的渲染组件第一次进行渲染的时候,这也就是当我们进入场景后某个物体首次出现在摄像机范围内是会有明显的掉帧现象;

4.shader 编译耗时

上边提到了shader的创建-初始化,以及编译。

了解完这些,心中会不会有个疑问:

  • 上边说创建-初始化不耗时,是不是编译耗时?

我们通过日志输出一下而WebGL2CmdFuncCreateShader函数调用时的时间

get gpuShader(): IWebGL2GPUShader {
    if (this._gpuShader!.glProgram === null) {
        console.time(this._name)
        WebGL2CmdFuncCreateShader(WebGL2DeviceManager.instance, this._gpuShader!);
        console.timeEnd(this._name)
    }
    return this._gpuShader!;
}

放大给大家看看

通过上边的图标中的log,我们可以看到,shader的编译不是一般的耗时;是不是终于了解到了你们游戏突然掉帧的原因了;

5.shader 编译条件

到这里还没有完,了解到这里我们只完成了整个过程的三分之一;

这里大家是不是在想:
shader的编译是在第一次调用gpuShader时,而在材质加载成功
后就已经初始化了,初始化完成后直接调用gpuShader是不是就可以了?

要是真有这么简单就好了,听宗宝接下来的分解

对于Shader 来说,有几种情况会导致shader需要重新编译

  • 1.源代码更改

也就是effect文件,而这个文件我们是在编辑器进行修改,构建后直接打包成json文件,运行的时候读取解析,并不支持我们在非编辑器模式下的修改

  • 2.宏定义值改变

这是咱们此次文章的重点, 传统的shader中,通过宏定义的方式来开启或关闭对应的特性;

6.shader 编译-宏定义

宏定义的改变,会导致对应的shader需要重新编译。所以我们需要了解一下Cocos Creator的宏定义管理原理,宗宝整理了一下,大概分为三类:

    1. effect

我们在编辑器中实现shader时,通过宏开关来控制shader中需要的特性;这些宏开关和对应的值会被存储到effect文件中,比如基础贴图,法线贴图等,也就是下图咱们常见的:

  • 2.全局

除了effect文件中可配置的,还有一部分宏开关是根据当前运行场景的状态决定的;也是我们scene节点中的SkyBox,Fog,Shadow等,

"CC_PIPELINE_TYPE": 0,
"CC_USE_HDR": true,
"CC_USE_DEBUG_VIEW": 0,
"CC_SHADOWMAP_FORMAT": 0,
"CC_SHADOWMAP_USE_LINEAR_DEPTH": 0,
"CC_SUPPORT_CASCADED_SHADOW_MAP": true,
"CC_SHADOW_TYPE": 2,
"CC_DIR_SHADOW_PCF_TYPE": 2,
"CC_DIR_LIGHT_SHADOW_TYPE": 2,
"CC_CASCADED_LAYERS_TRANSITION": false,
"CC_USE_IBL": 0,
"CC_USE_DIFFUSEMAP": 0,
"CC_IBL_CONVOLUTED": false,
"CC_USE_FOG": 4,
"CC_USE_ACCURATE_FOG": 0,
"CC_TONE_MAPPING_TYPE": 0

大概有这么多,这些是在运行时设置给shader的。

  • 3.特定功能

在部分功能也会有单独的宏开关,最特别的就是:BakedSkinningModel(GPU 预烘焙动画的蒙皮模型),他自己会涉及到两个宏开关

const myPatches = [
    { name: 'CC_USE_SKINNING', value: true },
    { name: 'CC_USE_BAKED_ANIMATION', value: true },
];

在实际的项目中,当任意的宏开关发生了变化,那么其实就是一个新的shader的参数;我们在场景中动态设置阴影,设置雾效等开关其实都有可能会创建一个新的shader文件,并且触发一次编译。

shader 缓存

了解Cocos Creator中shader 的缓存机制,是咱们实现shader预编译的重要因素;

只要的相关逻辑还是在program-lib.ts 的getGFXShader函数中

 public getGFXShader (device: Device, name: string, defines: MacroRecord, pipeline: PipelineRuntime, key?: string): Shader {
 ...
  return this._cache[key] = device.createShader(shaderInfo);
 }
 

重点在最后一行,他会用key将创建好的shader存储到_cache,下次如果使用相同的,直接获取,关键就是这个key的生成

public getGFXShader (device: Device, name: string, defines: MacroRecord, pipeline: PipelineRuntime, key?: string): Shader {
    Object.assign(defines, pipeline.macros);
    if (!key) key = this.getKey(name, defines);
    const res = this._cache[key];
    ...
}

前边提到了全局宏定义,就是第一行代码中的 pipeline.macros, 他会将efffct的宏定义和全局的进行整合,然后将通过指定逻辑生成。感兴趣的可以去深入一下代码;只有开启的对应宏开关才会参与到key的生成中

咱们已经提到了effect的宏定义,还有就是全局的宏定义,但是还有一个特定功能的宏定义,这个应该如何获取呢;

通过上边的代码,可以观察到宏开关决定的shader的唯一性,所以我们通过调试功能输出渲染组件会发现一个问题:其实subMesh属性的gfx.Shader的name属性中就已经体现出来了当前shader的名称,已经使用到的宏开关和对应的值 如下:

builtin-standard|standard-vs|standard-fs|CC_RECEIVE_SHADOW1|CC_FORWARD_ADD1|CC_USE_FOG4|CC_SUPPORT_CASCADED_SHADOW_MAP1|CC_USE_HDR1|CC_DIR_SHADOW_PCF_TYPE2|CC_SHADOW_TYPE2|CC_DIR_LIGHT_SHADOW_TYPE2|CC_IS_TRANSPARENCY_PASS1",
"builtin-standard|shadow-caster-vs|shadow-caster-fs|CC_RECEIVE_SHADOW1|CC_USE_FOG4|CC_SUPPORT_CASCADED_SHADOW_MAP1",
"builtin-standard|standard-vs|reflect-map-fs|CC_RECEIVE_SHADOW1|CC_USE_FOG4|CC_SUPPORT_CASCADED_SHADOW_MAP1|CC_DIR_SHADOW_PCF_TYPE2|CC_USE_HDR1|CC_SHADOW_TYPE2|CC_DIR_LIGHT_SHADOW_TYPE2",
"builtin-standard|planar-shadow-vs|planar-shadow-fs|CC_RECEIVE_SHADOW1|CC_USE_FOG4|CC_SUPPORT_CASCADED_SHADOW_MAP1",
"../village/res/shader/build|standard-vs|standard-fs|USE_INSTANCING1|CC_RECEIVE_SHADOW1|CC_USE_FOG4|CC_SUPPORT_CASCADED_SHADOW_MAP1|USE_NORMAL_MAP1|CC_DIR_SHADOW_PCF_TYPE2|CC_USE_HDR1|CC_SHADOW_TYPE2|CC_DIR_LIGHT_SHADOW_TYPE2|USE_ALBEDO_MAP1",
"../village/res/shader/build|standard-vs|standard-fs|USE_INSTANCING1|CC_RECEIVE_SHADOW1|CC_USE_FOG4|CC_SUPPORT_CASCADED_SHADOW_MAP1|USE_NORMAL_MAP1|CC_FORWARD_ADD1|CC_DIR_SHADOW_PCF_TYPE2|CC_USE_HDR1|CC_SHADOW_TYPE2|CC_DIR_LIGHT_SHADOW_TYPE2|USE_ALBEDO_MAP1",
      

那么我们是不是可以将用到的shader的name作为我们的突破口,它里边已经包含了当前材质用到的所有宏开关和值,我们字需要从name中反推出shader的名字,宏开关和值进行存储就可以了

预编译

1.shader 预编译数据导出

那么有了思路,我们就可以进行预编译shader配置数据的导出; 上边已经提到了,在实际的运行中,随着场景变化,比如阴影,雾效等参数的设置都可能会创建一个没有的shader,并进行编译。

宗宝的实现方式是:基于运行时

考虑到实际运行时的场景各种参数的调整都有可能产生新的shader,没办法在编辑器中快速的导出所需要的数据;我们的需求是经可能将场景中说有使用的shader相关的数据都拿到;所以宗宝采用基于运行时的shader数据获取;

所以宗宝在游戏运行界面添加了调试按钮:

在游戏操作的随时主动点击触发shader数据的更新,排除掉已有的

rivate traverseRenderShaders(): void {
    console.log("更新预编译shader数据...");
    let root: Node = director.getScene();
    //shader
    let renders: MeshRenderer[] = root.getComponentsInChildren(MeshRenderer);
    renders.forEach((render: MeshRenderer) => {
        if (render.model) {
            let subMeshs: renderer.scene.SubModel[] = render.model.subModels;
            subMeshs.forEach((subMesh: renderer.scene.SubModel) => {
                let shaders: gfx.Shader[] = subMesh.shaders;
                shaders.forEach((shader: gfx.Shader) => {
                    if (shader.name.indexOf("internal/editor") == -1) {
                        let isExist: boolean = false;
                        for (let i = 0; i < this._bakeRenderShaderDatas.length; i++) {
                            let shaderName: string = this._bakeRenderShaderDatas[i];
                            if (shader.name === shaderName) {
                                isExist = true;
                                break;
                            }
                        }
                        if (!isExist) {
                            this._bakeRenderShaderDatas.push(shader.name);
                        }
                    }
                });

            });
        }
    });
}

在最终游戏体验的差不多了,将数据导出

private exportRenderShaders(): void {
    console.log("导出预编译shader数据...");
    let defines: any = {};
    //@ts-ignore
    let templates: any = renderer.programLib._templates;
    for (let key in templates) {
        templates[key].defines.forEach((define: any) => {
            if (!defines.hasOwnProperty(define.name)) {
                defines[define.name] = { type: define.type }
            }
        });
    }
    // 将 JSON 数据转换为字符串
    let  jsonData = JSON.stringify({shaders: this._bakeRenderShaderDatas,defines: defines,}, null, 2);
    // 创建一个 Blob 对象,指定 MIME 类型为 application/json
    const blob = new Blob([jsonData], { type: 'application/json' });
    // 创建一个 URL 对象
    const url = URL.createObjectURL(blob);
    // 创建一个临时的 <a> 元素
    const a = document.createElement('a');
    a.href = url;
    a.download = 'precompile-shader.json';
    // 将 <a> 元素添加到 DOM 中,触发点击事件,然后移除该元素
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    // 释放 URL 对象
    URL.revokeObjectURL(url);
}

同时我们还需要对没个宏定义的类型就行存储,便于我们倒推宏开关的值;最终我们得到数据:

{
  "shaders": [
      "builtin-standard|standard-vs|standard-fs|CC_RECEIVE_SHADOW1|CC_USE_FOG4|CC_SUPPORT_CASCADED_SHADOW_MAP1|CC_USE_HDR1|CC_DIR_SHADOW_PCF_TYPE2|CC_SHADOW_TYPE2|CC_DIR_LIGHT_SHADOW_TYPE2",
      "builtin-standard|standard-vs|standard-fs|CC_RECEIVE_SHADOW1|CC_FORWARD_ADD1|CC_USE_FOG4|CC_SUPPORT_CASCADED_SHADOW_MAP1|CC_USE_HDR1|CC_DIR_SHADOW_PCF_TYPE2|CC_SHADOW_TYPE2|CC_DIR_LIGHT_SHADOW_TYPE2|CC_IS_TRANSPARENCY_PASS1",
      "builtin-standard|shadow-caster-vs|shadow-caster-fs|CC_RECEIVE_SHADOW1|CC_USE_FOG4|CC_SUPPORT_CASCADED_SHADOW_MAP1",
      "builtin-standard|standard-vs|reflect-map-fs|CC_RECEIVE_SHADOW1|CC_USE_FOG4|CC_SUPPORT_CASCADED_SHADOW_MAP1|CC_DIR_SHADOW_PCF_TYPE2|CC_USE_HDR1|CC_SHADOW_TYPE2|CC_DIR_LIGHT_SHADOW_TYPE2",
      "builtin-standard|planar-shadow-vs|planar-shadow-fs|CC_RECEIVE_SHADOW1|CC_USE_FOG4|CC_SUPPORT_CASCADED_SHADOW_MAP1",
      ....
    ],
    "defines": {
      "USE_LOCAL": {
        "type": "boolean"
      },
      "SAMPLE_FROM_RT": {
        "type": "boolean"
      },
      "USE_PIXEL_ALIGNMENT": {
        "type": "boolean"
      },
      "CC_USE_EMBEDDED_ALPHA": {
        "type": "boolean"
      },
      "USE_ALPHA_TEST": {
        "type": "boolean"
      },
      "USE_TEXTURE": {
        "type": "boolean"
      },
      ....
    }

2.shader 预编译

有了上边的数据,我们就可以开始实现我们的预编译逻辑了

  • 1.在enging/cocos/render-scene/core/program-lib.ts 中新增preCompileGFXShader函数
    ;与原有的getGFXShader相比,此处不需要考虑全局的宏开关,当前传进来的就是我们所需要的;其实与getGFXShader项目就改了一行代码;
public preCompileGFXShader (device: Device, name: string, defines: any, pipeline: PipelineRuntime): Shader {
    let key = this.getKey(name, defines);
    const res = this._cache[key];
    if (res) { return res; }

    const tmpl = this._templates[name];
    const tmplInfo = this._templateInfos[tmpl.hash];
    if (!tmplInfo.pipelineLayout) {
        this.getDescriptorSetLayout(device, name); // ensure set layouts have been created
        insertBuiltinBindings(tmpl, tmplInfo, globalDescriptorSetLayout, 'globals');
        tmplInfo.setLayouts[SetIndex.GLOBAL] = pipeline.descriptorSetLayout;
        tmplInfo.pipelineLayout = device.createPipelineLayout(new PipelineLayoutInfo(tmplInfo.setLayouts));
    }

    const macroArray = prepareDefines(defines, tmpl.defines);
    const prefix = pipeline.constantMacros + tmpl.constantMacros
        + macroArray.reduce((acc, cur): string => `${acc}#define ${cur.name} ${cur.value}\n`, '');

    let src = tmpl.glsl3;
    const deviceShaderVersion = getDeviceShaderVersion(device);
    if (deviceShaderVersion) {
        src = tmpl[deviceShaderVersion];
    } else {
        console.error('Invalid GFX API!');
    }
    tmplInfo.shaderInfo.stages[0].source = prefix + src.vert;
    tmplInfo.shaderInfo.stages[1].source = prefix + src.frag;

    // strip out the active attributes only, instancing depend on this
    tmplInfo.shaderInfo.attributes = getActiveAttributes(tmpl, tmplInfo.gfxAttributes, defines);

    tmplInfo.shaderInfo.name = getShaderInstanceName(name, macroArray);

    let shaderInfo = tmplInfo.shaderInfo;
    if (env.WEBGPU) {
        // keep 'tmplInfo.shaderInfo' originally
        shaderInfo = new ShaderInfo();
        shaderInfo.copy(tmplInfo.shaderInfo);
        processShaderInfo(tmpl, macroArray, shaderInfo);
    }
    let gfxShader:any= device.createShader(shaderInfo);
    try {
        gfxShader.gpuShader;
    } catch (error) {}

    this._cache[key] =gfxShader;
    return this._cache[key];
}  
  • 2.编译:在自己的游戏逻辑中加载预编译数据,进行解析。调用添加到引擎中的接口进行编译;宗宝这里进行了分帧处理,因为界面还有动画在播,保证动画的流畅性;
update(deltaTime: number) {
    if (!this._isPreCompile) return;
    let shader: any = this._precompileShaderDatas[this._index];
    let params: string[] = shader.split("|");
    let program: string = params[0] + "|" + params[1] + "|" + params[2];
    let defines: renderer.MacroRecord = {};
    for (let i = 3; i < params.length; i++) {
        let param: string = params[i];
        let macros: string[] = param.match(/^(.*?)(\d+)$/);
        if (macros) {
            let define: string = macros[1];
            let value: string = macros[2];
            if (this._precompilePipelineDefinesDatas.hasOwnProperty(define)) {
                let type: string = this._precompilePipelineDefinesDatas[define].type;
                if (type == "boolean") {
                    defines[define] = value == "1" ? true : false;
                } else if (type == "number") {
                    defines[define] = Number(value);
                }
            }
        }
    }
    //@ts-ignore
    renderer.programLib.preCompileGFXShader(director.root.device, program, defines, director.root.pipeline);
    if (this._progress) this._progress(this._index, this._precompileShaderDatas.length);
    this._index++;
    if (this._index >= this._precompileShaderDatas.length) {
        this._isPreCompile = false;
        if (this._complete) this._complete();
    }
}

总结

那么到这里;宗宝的基于H5平台的shader预编译方案的原理就结束了,整偏文章比较长,涉及的点比较多;上述的分享希望对有需要的小伙伴有所帮助;这也是宗宝目前为止写的最长的一篇文章,看完别忘了点赞啊

写到最后

更多干货请关注公众号:穿越的杨宗宝

30赞

对,这个确实是关键,我们粒子系统挺多,也都是shader编译会卡。我们也做了shader 预编译,Web端和原生的都做了。
具体做法和你这的差不多,收集的话,我们在原生和web端都增加了一个结构ShaderCompileRecord,会记录shader名称和宏定义。然后在引擎编译shader的时候记录数据。
运行时通过gm,将记录整体记录为json打出来,记录下来放到配置表下。预编译也是通过给shader增加一个接口tryCompile,在游戏启动的时候来进行预编译(也设置了一个最大预编译个数)。
我们不对BUILTIN的shader进行记录,只记录自己的shader,我们人物也好特效也好,都是从引擎shader库内复制一份出来方便修改,加入了不少自己的东西,所以只记录自己的。

在做的时候,我们发现粒子系统的宏定义开关特别的多。这个希望引擎组优化一下,尽量减少宏定义。有的地方例如粒子modules,适当采用if (uniform_variable)语句,减少一下变体数量,不会太耗的。laya听说优化了这个变体数量。
另外我也提了issue来建议官方提供类似unity的shader variant 记录的配置。方便收集和预编译。

2赞

哈哈,没想到有同道中人啊,我终于不孤单了 :sob:

好文当赞!!! :+1: :+1: :+1:


还有这么多,标题都写好了,就是没时间整理

2赞

用一个游戏实践造福我们呀,大佬真的牛逼 :+1: :+1:

:cow::beer::cow::beer::cow::beer:

留个脚印,哈哈

顶顶顶!!

这个确实是个痛点,果断收藏

:+1::+1:

源码就改了这段代码吗?不是很明白是什么意思。

laya早就是预编译的,,,

文章中有提到的,gpuShader会触发shader编译

那是不是可以用原本的getGFXShader方法,拿到gfxShader,外部手动调用一下gpuShader,而不去改动源码?

官方不能提供一个开关 自动预编译吗?

不行的…

文中提到unity也弄了,突然觉得cocos又多了个坑

微信小游戏怎么弄呢?有没有shader warmup的方法。

贴子里面的方法微信小游戏一样可以用的。