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!;
}
9f0f989b023d614e8a11f6d72a3a231b.png

放大给大家看看19d11d87d4f4f0d758f345d59036f5dc.png

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

5.shader 编译条件

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

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

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

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

  • 1.源代码更改

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

  • 2.宏定义值改变

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

6.shader 编译-宏定义

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

  • 1.effect

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

3bc5d699810812b7e81e01225aa0f650.png
  • 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数据获取;

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

11fe66bccbe1fc74ab9c17e3920cc223.png

在游戏操作的随时主动点击触发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);
                        }
                    }
                });

            });
        }
    });
}

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

a10c31caf89729e6938e3c2d34f8faae.png
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预编译方案的原理就结束了,整篇文章比较长,涉及的点比较多;上述的分享希望对有需要的小伙伴有所帮助;这也是宗宝目前为止写的最长的一篇文章,看完别忘了点赞啊!

786bcf3b99ebc17d87513b6b81dcfaed.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值