基于Cocos的简易体积阴影/光踩坑大全,汗流浃背了吧!

1735120596715-20241225_175523

先上预览地址:cocos-godray-dist

源码地址:cocos-godray

PS. 如果一开始是黑的,那就用鼠标转一下

万恶之源

做独立项目的时候,boss说海底好看啊,blingbling的,我说我懂,焦散+体积光嘛,于是找个了简单的纹理加上UV动画当作焦散效果,但是体积光又怎么办呢?用面片加UV动画太low,干脆用 Cocos Creator 3.8 做个基于屏幕空间的体积光吧!(头发,out!)

原理什么的

网上有很多大佬都讲解过了,比如:
基于后处理光线步进的体积光的实现

搞个模型

先做个技术验证,在sketchfab找个好看的模型:

点赞下载解压一气呵成(我这里下载的是原格式),加个 license 感谢作者大大:

"Forest House" (https://skfb.ly/6QSKT) by peachyroyalty is licensed under Creative Commons Attribution-NonCommercial (http://creativecommons.org/licenses/by-nc/4.0/).

模型导入cocos 以后,材质不对劲!好端端的贴图全都叛变了:

看了下原模型和贴图,其实用不受光材质就可以,于是这里把effect改成不受光材质,然后手动把所有的贴图全部重新拖上去:

全部操作完以后,我们会发现像树木这种贴图中带有透明像素的显示不正确,把ALPHA_TEST勾选上就好啦

深度图怎么办

在Cocos中获取深度图是个论坛上很多人在问的问题,大部分老版本的思路就是自己写一个深度材质,配置一个深度专用的layer,再用一个专门的相机去渲染一次场景;在3.8的自定义管线出现以后,可以通过自己增加一个Pass来获取深度图。

我想了一下,我需要在场景中获取两张深度图,一个是通过屏幕坐标用来反算世界坐标的,一个是用来作为shadowMap用来计算阴影的,干脆就用老方法(才不是因为自定义管线很困难)来获取深度图,新建一个layer叫做DEPTH,新建一个深度材质,加上对应的相机,再加两个render-texture,咔咔一顿操作,两张深度图就有了。

这里需要注意的是,由于我们在制作材质时,有的材质勾选了USE ALPHA TEST,所以我们的深度材质也要把透明度的判断逻辑加上,然后在复制场景的时候,把之前老的材质的贴图给复制过去:

// 复制场景,添加深度材质,然后复制贴图
private _setScene() {
    this._clone = instantiate(this.scene);
    setLayer(this._clone, DEPTH_LAYER);
    this.scene.parent.addChild(this._clone);
    this._clone
        .getComponentsInChildren(Renderer)
        ?.forEach((renderer) => {
            const mat = renderer.getSharedMaterial(0);
            const tex = mat.getProperty("mainTexture") as Texture2D;
            renderer.setMaterialInstance(this.depthMaterial, 0);
            renderer.getMaterialInstance(0).setProperty("mainTexture", tex);
        });
}

这里需要注意,使用setMaterialInstance方法来设置材质,因为每张贴图都不一样。

对应的shader代码:

vec4 depth_8bits () {
  vec4 texColor = texture(mainTexture, v_uv);
  if (texColor.a < 1.0) discard;

  float depth = v_screenPos.z / v_screenPos.w * 0.5 + 0.5;
  vec4 col = vec4(depth, depth, depth, 1.0);
  return CCFragOutput(col);
}

深度图获取到了,接下来就是计算阴影

计算阴影

阴影的计算,大致思路是先从屏幕空间出发,通过深度图构建该点在世界坐标的位置,然后向视角方向多取几个点,看看这一系列点是否都在阴影里,并把这些结果累加起来。用一张图来表示的话大致就是这个意思:

计算的主要shader代码如下:

  1. 通过深度图获取当前屏幕坐标的对应的深度
float depth = unpackRGBAToDepth(texture(depthTexture, v_uv));
  1. 通过逆投影视图变换获取到世界坐标,可以参考这个帖子
vec4 ndcPos = vec4(vec3(v_uv, depth) * 2.0 - 1.0, 1.0);
ndcPos = cc_matProjInv * ndcPos;
ndcPos /= ndcPos.w;
vec4 worldPos = cc_matViewInv * ndcPos;
  1. 以这个点为起点,向视角方向多取几个点,看看这些点在不在阴影里,然后累加起来!
vec4 dir = normalize(cc_cameraPos - worldPos);

float stepSize = godrayParam.x;
float totalShadow = 0.0;
int actualStep = int(length(worldPos - cc_cameraPos) / stepSize);

for (int i = 0; i < MAX_SETP; i++) {
  if (i >= actualStep) {
    break;
  }
  vec4 rayPos = worldPos + dir * float(i) * stepSize;
  totalShadow += shadowStrength(shadow_matViewProj, rayPos, shadowTexture);
}

float light = 1.0 - totalShadow / float(MAX_SETP);

代码有了,接下来的问题是,应该写在哪里?

自定义管线?

建立工程的时候我使用的版本是3.8.4,很不幸的是,想要使用使用builtin-pipeline且自定义后效的话是打咩的,且没有文档

啊这,如果图省事的话有个取巧的办法,像渲染深度图一样,做点骚操作,我们新建一个分组和相机,然后放置一个面片在这个相机面前,然后把材质赋给这个面片,就可以假装实现了屏幕后效 :sweat_smile: :sweat_smile: :sweat_smile:

但是,实在是太不优雅了!又刷了刷论坛,发现官方说3.8.5的 CRP 管线支持添加 PipelinePass,果断升级一波!

升级以后我们按照官方代码新建一个 GodrayPassBuilder 用来实现后效,但这里的类型支持有点不理想,很多内置的类型,函数并没有暴露在外面,使用起来不太方便。需要注意的有几个地方:

  1. 官方的BuiltinPipelineSettings并没有暴露出来,想要获取组件只能用字符串,然后any大法,设置里面的_passes属性也是用的字符串:
onEnable() {
    this._settings = (
        this.getComponent("BuiltinPipelineSettings") as any
    ).getPipelineSettings();
    if (!Object.prototype.hasOwnProperty.call(this._settings, "_passes")) {
        Object.defineProperty(this._settings, "_passes", {
            value: [],
            configurable: false,
            enumerable: false,
            writable: true,
        });
    }
    this._settings._passes.push(this);
}
  1. 自定义pass后,记得维护cameraConfigs内部的remainingPasses计数(这里的类型也全都是{ readonly [name: string]: any }):
configCamera(
    camera: Readonly<renderer.scene.Camera>,
    pplConfigs: { readonly [name: string]: any },
    cameraConfigs: { [name: string]: any }
) {
    cameraConfigs.applyGodray = this.enabled && !!this.material;
    if (cameraConfigs.applyGodray) {
        cameraConfigs.enableFullPipeline = true;
        cameraConfigs.remainingPasses++;
    }
}
  1. 想要自定义后效生效,cameraConfigs.enableFullPipeline应当为true,但是从源码来看默认管线只有在layer为DEFAULT时才生效(具体原因没有研究,可能和光照相关?),因此想要给其他layer的相机加后效则需要手动设置一下
// editor\assets\default_renderpipeline\builtin-pipeline.ts

cameraConfigs.enableFullPipeline = (camera.visibility & (Layers.Enum.DEFAULT)) !== 0;

// ...

if (this._cameraConfigs.enableFullPipeline) {
    this._buildForwardPipeline(ppl, camera, camera.scene, this._passBuilders);
} else {
    this._buildSimplePipeline(ppl, camera);
}
  1. 管线内部使用的接龙命名函数,没有暴露在外面,我们可以自己拷贝一份:
protected getPingPongRenderTarget(
    prevName: string,
    prefix: string,
    id: number
): string {
    if (prevName.startsWith(prefix)) {
        return `${prefix}${1 - Number(prevName.charAt(prefix.length))}_${id}`;
    } else {
        return `${prefix}0_${id}`;
    }
}
  1. 管线内部添加的很多资源名称,并没有暴露出来,且各个pass之间有耦合,这个地方的代码组织感觉不是最理想的状态。

anyway,添加好后效以后,我们终于能把体积阴影渲染出来了,接下来如法炮制,给主相机加一个后效用来应用体积光效就可以了。

参数地狱

按理说,整个工程到了这里基本上就没什么技术难题了,但其实当中有很多参数问题并没有解决,举几个例子:

  1. shadowMap该有的锯齿不会少:项目里纹理设置多大,bias怎么计算合理?
  2. 阴影边缘做弱化,弱化怎么处理,阈值如何?
  3. 计算阴影累加:步进操作迭代多少次?每次步进多少距离?
  4. 主相机屏幕后续如何应用计算出的体积光贴图,参数怎么设置?

时间不早,头发不多,随便调一下,睡觉 :rofl:

再贴几个广告:

yet another 3D鱼竿模拟组件
yet another 伤害飘字组件

11赞

mark一波

小母牛坐飞机 :+1:

很棒的分享,我现在还在3.8.4中通过camera+rendertexture的方式做多pass后效处理,写了个脚本可以自动生成相机和面片,流程虽然粗糙但也算简单可控
新的管线感觉还很不完善呀,不太敢用,折腾不动,等迭代几个版本再说
不过话说3.8刚发时说使用了新的渲染管线
到3.8.4又升级使用了新的渲染管线
这新的也太快了点

之前我也想过自动生成相机和面片,感觉这种方式是适用版本最多,也最方便操作的,毕竟在编辑器里拖放节点比照着源码改管线的心智负担小很多,而且改代码的方式,总需要注意一些奇奇怪怪的细节

其实在3.8.4也尝试过自定义管线,在多个pass里分别把两张深度图,体积光,以及最后的合成全部处理了;
出于性能考虑,我想要的效果是只有在场景变化后,再去触发对应pass的渲染逻辑,理想状态就是把前几个pass中的texture保存下来,只在必要时更新,最后一个pass每帧更新:


但是不知道怎么实现,求助官方大佬!
@zlzhou.sh

3.8.5开始可以自定义PipelinePass,建议仿照DOF新增一个Component。
在这个Component里,注册Texture时,把ResourceResidency改为Persistent。这样管线不会释放贴图,会一直持有贴图内容。
在需要更新的时候,控制Component开启所需流程,渲染结果到Texture1、Texture2。
完成更新后,控制Component关闭绘制流程。
随后的Pass,可以使用Texture1、Texture2进行渲染。

目前没有合适的例子体现这个功能,之后会想办法做一个。

2赞

明白思路了,多谢

太猛了,马克了