Shader学习-从Shader新手到简单2d实时全局光照#

1. 背景#

学过一些初级的图形学/渲染。
折腾bevy途中碰到一个2d全局光照例子,看着还挺唬人的。希望搞懂其原理。
zaycev/bevy-magic-light-2d

其中shader早就有大概的了解,但一直没进一步学习,知道涉及很多trick和数学。
希望更进一步,搞懂shadertoy的基本玩法和一些特效的原理,搞懂bevy的基本shader流程。

步骤

  1. 搞清楚shadertoy和基本的glsl shader是咋回事儿

  2. 初步学习games202<<高质量实时渲染>>课程

  3. WebGPU初步学习

  4. 看懂Bevy的一个2d全局光照项目

  5. 山寨一个sdf全局光照插件


2. 资料#

shader
https://www.shadertoy.com/
https://thebookofshaders.com/
https://www.youtube.com/watch?v=f4s1h2YETNY
https://www.youtube.com/@TheArtofCodeIsCool/videos
https://clauswilke.com/art/post/shaders
https://iquilezles.org/articles/

计算器
https://www.desmos.com/calculator
https://www.desmos.com/3d

games202
https://sites.cs.ucsb.edu/~lingqi/teaching/games202.html
https://www.bilibili.com/video/BV1YK4y1T7yY


3. 基础#

shader有不同的类型和框架。
最简单的一种fragment shader可以理解为一个回调函数。
常见流程是先摆好场景,渲染出图,把图喂给你这个shader。
在shader里处理图中的每一个像素。
例如在shader里把所有像素都乘0.5,整个图就变暗。
诸如此类,可以做出各种特效。

也可以无输入,纯用shader画各种酷炫的图。
shadertoy有大量这种。


直接开始在 https://www.shadertoy.com/ 写代码

看这个聪明绝顶教程
https://www.youtube.com/@TheArtofCodeIsCool/videos
看他最老的几个基本的教学,涵盖了一些基本的画图套路。
有耐心的可以一直看下去。

看这个最简单的sdf
https://www.shadertoy.com/view/3ltSW2
学到一些画图技巧

自己再找找资料学学。
这样折腾两天,搞清楚基本的套路,可以搞一些图了。
https://www.shadertoy.com/view/wfl3z8
https://www.shadertoy.com/view/M3GBDt
https://www.shadertoy.com/view/MXyfDt


4. Ray Marching#

4.1 初步#

以前我们在学pbr的体渲染时已经学过ray marching。
在有些无法轻易精确计算的场合,直接模拟光的推进并采样,能快速得到一个不完美但足够好的结果。


https://youtu.be/PGtv-dBi2wE?t=274

总体就是光从相机p1以某个方向a往场景里打,每次前进一个距离,直到碰到物体或者超过限制的前进次数放弃。

  1. 计算目前光的位置(初始为p1)到场景中物体的最短距离d。如果d小于某个很小的阈值,就认为现在光正好打到了某个物体表面,结束。

  2. 否则,这个距离是一个安全的距离,我们可以把光在a方向往前推进d到达p2,而绝不会穿过任何物体。

如此不断循环推进,正常情况会推进到物体和光的接触点。


理解原理后可以尝试不看他代码自己写一个大概的。
用ray marching算出每条光与场景的交点,并想办法画出来。
https://www.shadertoy.com/view/wfXGR4


4.2 简单光照#

定一个光源,计算交点的normal,再计算normal方向的分量,作为光强度的因子,再加点衰减啥的,可以得到基本的光照。

normal的计算见 https://iquilezles.org/articles/normalsSDF/
结合聪明绝顶的视频和代码。

我最容易理解的normal近似计算方法
自己画一下图就很清楚。

  1. normal对应的是交点处的微小平面。

  2. 交点实际位于a的沿normal方向的距离d处。d是4.1算法中的d。

  3. 从交点处往xyz轴延申一个微小的距离得到三个点。

  4. 计算这个三个点到场景的距离d1/d2/d3。

  5. normal的近似值其实就和d1/d2/d3统一减去d后的比值对应。

那么normal大概就是这种形式

float d = dist_to_scene(p);

vec2 e = vec2(0.01, 0);

vec3 normal = vec3(dist_to_scene(p + e.xyy) - d, 
                   dist_to_scene(p + e.yxy) - d, 
                   dist_to_scene(p + e.yyx) - d);

normal = normalize(normal);

简单的阴影需要再做一次ray marching,从交点往光源推进。
如果距离小于交点到光源的距离,说明中间碰到了物体,就是在阴影中。
简单乘个0.1之类即可。

最后得到 https://www.shadertoy.com/view/Wcs3zM


5. SDF(Signed Distance Function/Field)#

https://en.wikipedia.org/wiki/Signed_distance_function

sdf是一个函数,在一个场景中,给出任何一个点,sdf可以得出这个点到场景中最近的一个微小表面的距离。
如果值是正数,那么点在面的外部,如果是负数,点在面的内部

需要把场景分成各种基本形状,每种基本形状都有自己的sdf。

之前看过这个 https://www.shadertoy.com/view/3ltSW2
是最简单的圆形的sdf。
我的 https://www.shadertoy.com/view/wfl3z8

各种基本形状的sdf总结见 https://iquilezles.org/articles/distfunctions2d/


我们要搞懂长方形的sdf。
先看无位移无转动的长方形。
推导见
https://numbersmithy.com/
https://www.youtube.com/watch?v=62-pRVZuS5c

其中的max挺搞的,vec2 max(vec2 a, float b)这种max是用b分别去比a的xy然后得到新的vec2。
不知道的话有点看不懂。

自己实现一下
得到 https://www.shadertoy.com/view/tfl3Dr
只要在 https://www.shadertoy.com/view/wfl3z8 基础上实现一下长方形的sdf替换圆的sdf即可,非常优雅。


对于有位移有旋转的长方形,先做两个操作,把长方形移到(0,0),用矩阵把它转正。
再把请求的位置也做一样的变换。
最后求标准的sdf即可。

得到 https://www.shadertoy.com/view/WffGWH


5.1 sdf-raymarching#

简单结合sdf和raymarching就能搞出非常唬人的效果。
https://iquilezles.org/articles/raymarchingdf/

软阴影
https://iquilezles.org/articles/rmshadows/
https://www.shadertoy.com/view/lsKcDD

2d软阴影
https://www.shadertoy.com/view/4dfXDn

sdf/采样/全局光照
https://www.shadertoy.com/view/lldcDf


6. games 202#

这一节其实不用看。不过对shader学习很有帮助。
因为开始不清楚各种关系,所以学了一下。

其实上一节最后的sdf全局光照基本就是我们最开始的学习目标了。
也就是这个项目的基本原理
zaycev/bevy-magic-light-2d
而games202主要走的是预计算,跟我们的目标是两个方向。


看完基本的shadertoy和sdf后,对于实时全局光照仍然是一头雾水的。
只是听说实时全局光照会用到sdf,但是完全不懂怎么联系起来的。
想起来前几年学过一个课程叫games101,讲了初步的渲染。然后学了pbrt离线光线追踪。
games101后面还有一个games202课程,讲的正是实时渲染,会涵盖我们的问题。
当时学离线的光线追踪人已经麻了,没有再继续学实时。
现在得补起来。

https://sites.cs.ucsb.edu/~lingqi/teaching/games202.html


6.1 作业0#

就是熟悉一下整体流程。

大概就是:
three.js加载模型,加载材质,加载shader。
three.js调opengl接口画图。
我们写shader,实现最简单的Blinn–Phong。

Blinn–Phong非常简陋。但是粗看也能像那么回事儿,作为教学很ok了。
就是简单模拟ambient/diffuse/specular三个光,加起来。

需要修改的代码都在作业pdf中,看懂没问题。
不需要看细节,看懂大致流程和shader即可。
运行方法也在pdf中。

有时候刷新不显示模型。
https://games-cn.org/forums/topic/zuoye0-jieguobuwendingyoushimoxingxianshibuquan/


6.2 硬阴影#

shadow mapping
https://learnopengl.com/Advanced-Lighting/Shadows/Shadow-Mapping

原始的shadow mapping是硬阴影。

步骤

  1. 以光源的视角渲染整个场景,不计算颜色,只保存每个像素对应的3d坐标的z值z1。每个像素也对应一条光。

  2. 保存这张图,即shadow map。坐标离光源越远,z值越大。把shadow map显示出来可以看到越远处越亮。

  3. 以正常的相机视角渲染,对于每个像素,对应到一个3d坐标a,手动计算a在光源视角下的当前z值z2。

  4. 比较z2和z1。如果z2大于z1,说明在这条光的方向上,存在一个离光源更近的3d坐标,即当前坐标被某个物体遮挡了,即在阴影中。

加bias解决明显的波纹。
但是会造成浮空,误杀了边界处阴影。


6.3 软阴影#

https://developer.download.nvidia.com/shaderlibrary/docs/shadow_PCSS.pdf

https://www.realtimeshadows.com/sites/default/files/sig2013-course-softshadows.pdf

完美的点光源和平行光没有软阴影。
面光源产生软阴影。

软阴影更自然,现实中看到的基本都是软阴影。


PCF(percentage closer filtering)
在上述比较z2和z1时,同时比较该点周围,做平均。即filtering。
例如跟shadow map中当前点比较,在阴影得到0。跟左边一个像素比较,不在阴影得到1。平均就能得到0.5。
可以取周围3x3或7x7之类的平均值。
这样在硬阴影的边界处能得到一堆0/1之间的数。
相比硬阴影的非0即1,就得到了软阴影。

具体怎么取周围的点,可以写最简单的周边3x3之类,效果简陋。
可以用各种采样函数,水就很深了。
教程里提供了经典的泊松圆盘采样和uniform采样。

一用上pcf,那种实时阴影颗粒的感觉就出来了。


PCSS(percentage closer soft shadows)
有物理现象,物体距离障碍物越近的地方,产生的阴影越硬。
拿个笔立在桌面就能看到,靠近桌面的部分产生的阴影很硬,远端的阴影逐渐散开。
对不同距离的阴影做不同大小的filtering可以做出这种效果。

shadow map严格来说是点光源。
软阴影是面光源,使用点光源的shadow map来近似。
所以shadow map中的像素对应的一个3d坐标,实际上可能是光源多条光线的blocker。


variance soft shadow mapping


6.4 作业1#

要求实现shadow mapping/pcf和pcss。

6.4.1 shadow mapping#

单纯的Blinn–Phong是不能把阴影投到别的模型上的。
shadow map可实现最简单的阴影投射。

loadOBJ会指定PhongMaterial,起material和shadowMaterial两个材质。
对应phongShader和shadowShader两个shader,即模型本身材质的渲染和阴影渲染。

engine.js里会初始化光源数据。
光源是固定的,只有一个光源。

复习mvp矩阵
http://www.xiongchen.cc/blog/PBRT.html#transformation

DirectionalLight.js中

在WebGLRenderer.js的render()中会先做shadow pass,对应ShadowMaterial,渲染shaodow map。
也就是对应了shadow的shader。
然后camera pass,对应PhongMaterial,也就对应phong的shader。

vertex shader在fragment shader之前执行

shadowVertex.glsl中

  • aVertexPosition是顶点的模型坐标数据。

  • uLightMVP * vec4(aVertexPosition, 1.0);
    这样mvp变换到光源的clip空间。
    uLightMVP是上面算好的光源的mvp变换。

shadowFragment.glsl中

  • 总之得把基本流程看熟,opengl基本数据要搞清楚,那几个空间变换要搞清楚。

  • gl_FragCoord的xy是从0开始,单位是窗口的像素,比如范围为[0, 1024]。

  • gl_FragCoord.z的默认范围是[0, 1]

  • pack函数把z值编码到一个vec4中。
    opengl的float类型是IEEE 754单精度浮点数,32bit。
    gl_FragColor默认填入的范围是[0, 1]。
    texture默认是rgba格式,每个通道8bit,也就是[0, 255]。
    那么如何存这个[0, 1]的z值?如果直接放到一个通道,那么float会变成[0, 1]之间的256级的某个数,精度退化到不可接收。所以要利用4个通道一共32bit来手动存这个z值。
    他的具体原理我也没看懂,先不管了,直接用。
    pack float to rgba能找到一些资料。
    https://aras-p.info/blog/2009/07/30/encoding-floats-to-rgba-the-final/

  • 把ShadowMaterial初始化的light.fbo给去掉,把WebGLRenderer中的Camera pass去掉,可以调试shadow pass的结果。直到可以画出正确的shadow map。

  • mvp变换后得到的xyz值还需手动除以w,才能得到范围[-1, 1]。如果传给gl_Position是不需要手动normalize的,非常坑。

  • 手动normalize后的z值和gl_FragCoord.z是相等的。正因如此我们才能手动算z值跟shadow map比大小。

  • https://gamedev.stackexchange.com/questions/66110/when-is-the-z-coordinate-normalized-in-glsl

  • https://www.songho.ca/opengl/gl_projectionmatrix.html

phongVertex.glsl

  • vFragPos = (uModelMatrix * vec4(aVertexPosition, 1.0)).xyz;
    这样乘上uModelMatrix得到世界坐标。
    计算blinnPhong就是用统一的世界坐标。

  • gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aVertexPosition, 1.0);
    这样mvp变换到camera的clip空间。

phongFragment.glsl

  • 把顶点做光源的mvp变换并手动normalize。再从[-1, 1]手动映射到[0, 1]。

  • 用去xy查shadow map,再用z去比较即可。


6.4.2 pcf#

phongFragment.glsl

  • 打开PCF,调用poissonDiskSamples生成采样点。

  • 生成的采样点需要按实际情况调参缩放,放的越大,阴影越软。

  • 采样函数里的参数也可以调,找一组舒服的数值即可。

  • 对于每个采样点,对比shadow map,得到0/1。

  • 最后取平均即可

6.4.2 pcss#

phongFragment.glsl

  • 打开PCSS,调用poissonDiskSamples生成采样点。

  • 对于每个采样点,如果在阴影里,累计z值。最后算出平均z值,也就是d_blocker。

  • 按相似三角形算出半影的宽度,对应阴影的软硬程度。

  • 计算宽度用的是z值,这里按我理解z值并不是线性的,所以宽度也不是线性的,并不是真的相似三角形。

  • 其中有多个参数可调,效果天差地别,需要费劲调试,搞懂各个步骤,搞懂各个数值的范围和效果。

  • 最后用这个软硬程度值调用PCF即可。

到此基本的fragment shader已经有点会了。


6.5 实时环境光照#

IBL(Image based lighting)

环境光shading。
渲染方程的近似,把Li项拿到外面。
Li的积分可以预计算。

环境光的阴影
precomputed radiance transfer

频域和滤波

基函数

spherical harmonics
https://en.wikipedia.org/wiki/Spherical_harmonics
是定义在球面上的一系列2d函数,样子和傅立叶变换的结果很相似。

https://cseweb.ucsd.edu/~ravir/prtsurvey.pdf

大致的原理估计就是利用球谐函数,类似傅立叶变换那样,可以有效地分解还原光照相关的数据。
然后可以快速应用在实时光照的预计算上。


到此暂停games202。因为了解到此时是跟sdf走的两条路了。
后续有缘再见。


7. WebGPU#

https://en.wikipedia.org/wiki/WebGPU
https://www.w3.org/TR/webgpu/
https://sotrh.github.io/learn-wgpu/
gfx-rs/wgpu
https://www.bilibili.com/video/BV12i4y1d7KQ
https://webgpufundamentals.org/

WebGPU感觉内容巨多但资料稀少,慢慢学吧。
要搞清楚各种概念和他们的关系。
WebGPU是全新的一套设计和api。
WebGPU是基于Vulkan这些底层api的,它最终会调用Vulkan/Dx等的api。
WebGPU有多种语言实现,wgpu是WebGPU的rust实现,重量级的项目。
浏览器基本都有js的原生支持,非常方便。官方文档就是js版本。
可以找个js版本的教程或书快速过一遍WebGPU基本套路。
WGSL是WebGPU唯一支持的shader语言。但wgpu支持了其他常见的shader语言,会把他们转成WGSL。


7.1 WGSL#

https://www.w3.org/TR/WGSL
https://google.github.io/tour-of-wgsl/
https://webgpufundamentals.org/webgpu/lessons/webgpu-wgsl.html

内容多但资料少
多用最新的ai搜一下,有非常不错的资料。

https://grok.com/chat/d4e9cb6b-a501-4a76-84af-06f63c7881fc

group(0)到底啥意思?
写0画不出来,写1崩溃,写2正常。
https://chatgpt.com/c/67cea5fb-6dd0-800e-8256-5a87491d723b

#import不是标准wgsl。是bevy的扩展。
import的是shader代码而不是rust代码。

#import bevy_pbr::mesh_view_bindings::globals
这个globals是mesh_view_bindings.wgsl里的
@group(0) @binding(11) var<uniform> globals: Globals;
Globals的更新在bevy_render/src/globals.rs,总体在RenderPlugin下。
但是绑定写死为0,不一定能用?


7.2 compute shader#

https://www.w3.org/TR/WGSL/#compute-shader-workgroups
https://webgpufundamentals.org/webgpu/lessons/webgpu-compute-shaders.html

compute shader可以并行执行任务。

workgroup指一组并行调用。

workgroup grid指一组调用里的所有点,每个点是一个三维的形式。

@compute @workgroup_size(8, 8, 1)
workgroup_size指定xyz三个维度大小。默认值为1。
这样每个group会计算8x8x1=64次。

内置变量local_invocation_id的值就是(x, y, z)。

所有workgroup又做成一个三维的结构,每个workgroup里是三维的一个个调用。
整个是两层三维结构。

内置变量workgroup_id,是外层的三维坐标。

内置变量global_invocation_id是每个调用的id。
由workgroup_id,workgroup_size和local_invocation_id算得。
pass.dispatchWorkgroups(4, 3, 2)
如果这样调用,就是执行4x3x2个8x8x1的group,一共会有4x3x2x8x8x1=768次调用。

内置变量num_workgroups是调用dispatchWorkgroups传的参数值。
大概就是本次计算的规模,workgroup每个维度的数量。

在GPUDevice.limits中各种硬件最大值。
例如maxComputeWorkgroupSizeX:256

。。。

8. Bevy shader#

首先会发现一个问题,把glsl的fragCoord.x作为color返回,显示的渐变不均匀。
立即会怀疑bevy对color空间有什么魔改。
后来看到这个,果然是的。需要加个色彩空间的转换。
bevyengine/bevy#8937

bevy-magic-light-2d这个项目最后的shader里也实现了lin_to_srgb这个函数。


我的一个最简例子
实现 https://www.shadertoy.com/view/lldcDf 这样的场景

  1. 做一个长方形

  2. 起一个material,绑定参数。

  3. material指定fragment_shader

  4. shader移植之前学到的sdf-raymarching。

use bevy::{
    prelude::*,
    reflect::TypePath,
    render::render_resource::{AsBindGroup, ShaderRef},
    sprite::{Material2d, Material2dPlugin},
};

fn main() {
    App::new()
        .add_plugins((DefaultPlugins,
            Material2dPlugin::<SimpleMaterial>::default(),))
        .add_systems(Startup, setup)
        .add_systems(Update, update_material)
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<SimpleMaterial>>,
) {
    // Spawn a 2D entity with the mesh and material
    commands.spawn((
        Mesh2d(meshes.add(Rectangle::default())),
        MeshMaterial2d(materials.add(SimpleMaterial {
            color: LinearRgba::WHITE, //LinearRgba::BLUE,
            time: 0.0,
        })),
        Transform::default().with_scale(Vec3::splat(512.)),
    ));

    // Add a camera
    commands.spawn(Camera2d);
}

// Define the shader material
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
//#[uniform(0)]
struct SimpleMaterial {
    #[uniform(0)]
    pub color: LinearRgba,

    #[uniform(0)]
    pub time: f32,
}

impl Material2d for SimpleMaterial {
    fn fragment_shader() -> ShaderRef {
        // The WGSL file that contains our shader code.
        "shader/sdf_light_1.wgsl".into()
    }
}

fn update_material(time: Res<Time>, mut materials: ResMut<Assets<SimpleMaterial>>) {
    // Update all TimeMaterials with the current time in seconds.
    for material in materials.iter_mut() {
        material.1.time = time.elapsed_secs();
    }
}
// sdf_light_1.wgsl

#import bevy_sprite::mesh2d_vertex_output::VertexOutput

struct SimpleMaterialUniform {
    color: vec4<f32>,
    time: f32,
};

// Define a uniform with our color.

// todo:group到底怎么配?
@group(2) @binding(0)
// var<uniform> u_color: vec4<f32>;
var<uniform> my_material: SimpleMaterialUniform;


var<private> W = 1.2 / 2.0; // 10.0;
var<private> H = 0.8 / 2.0; // 6.0;
var<private> X = 0.0;
var<private> Y = 0.0;
var<private> ROTATE = 0.0;

fn sdf_circle(p: vec2<f32>, r:f32 ) -> f32 {
    return length(p) - r;
}

fn sdf_box(p: vec2<f32> ) -> f32 {
    // 放到0,0
    var pp = p - vec2(X, Y);
    
    // 转正
    pp = mat2x2f(cos(ROTATE),-sin(ROTATE), sin(ROTATE),cos(ROTATE)) * pp;
    
    let q = abs(pp) - vec2(W/2.0, H/2.0);

    // > 0
    let d_outside = length(
        vec2(max(q.x, 0.0), max(q.y, 0.0))
    );
    
    // < 0
    let d_inside = max(q.x, q.y);
    
    return d_outside + min(d_inside, 0.0);
}

// 如果此obj离得更近,标记为此obj。
fn check_obj(
    dist: ptr<function, f32>, color: ptr<function, vec3<f32>>, 
    dist_to_obj: f32, obj_color: vec3<f32>) {
    if(dist_to_obj < *dist){
        *dist = dist_to_obj;
        *color = obj_color;
    }
}

fn query_scene(pos: vec2<f32>, color: ptr<function, vec3<f32>>, dist: ptr<function, f32>) {
    *dist = 1e9;
    *color = vec3(0.0);

    check_obj(dist, color, sdf_box(pos), vec3(0.0, 0.0, 0.0));

    check_obj(dist, color, 
        sdf_circle(pos - vec2(0.1* sin(my_material.time), 0.2* cos(my_material.time)), 0.1), 
        vec3(0.1, 0.1, 0.2));

    check_obj(dist, color, 
        sdf_circle(pos - vec2(0.6, 1.4* cos(my_material.time + 0.2)), 0.2), 
        vec3(0.0, 0.1, 0.3));

    check_obj(dist, color, 
        sdf_circle(pos - vec2(-0.6, .2* sin(my_material.time + 0.3)), 0.2), 
        vec3(0.2, 0.5, 0.3));

    check_obj(dist, color, 
        sdf_circle(pos - vec2(-2.0, .33* cos(my_material.time + 0.3)), 0.2), 
        vec3(0.9, 0.0, 0.0));
}

fn ray_march(pos: vec2<f32>, dir: vec2<f32>, color: ptr<function, vec3<f32>>) {
    var p = pos;
    let org_pos = pos;
    for(;;) {
        var dist = 0.0;
        
        // 走到p,得到离p最近的物体的颜色。
        query_scene(p, color, &dist);

        // 如果离物体非常近,视为碰到物体,返回。
        if dist < 1e-2 {
            // 这里可以做一些魔改
            // *color *= 1.1 / pow(length(p - org_pos), 2.0);
            *color *= clamp(4.1 / pow(length(p - org_pos), 2.0), 0.0, 3.0);
            return;
        }

        // 如果迟迟碰不到物体,返回黑色。
        if dist > 1e4 {break;}

        // 走一步
        p += dir * dist;
    }

    *color = vec3(0.02);
}

fn random (st: vec2<f32>) -> f32 {
    return fract(sin(dot(st.xy,
        vec2(12.9898,78.233)))*
        43758.5453123);
}

@fragment
fn fragment(mesh: VertexOutput) -> @location(0) vec4<f32> {
    // [0, screen_width] norm to [-1, 1]
	// vec2 uv = (2.0*fragCoord-iResolution.xy)/iResolution.y;
    var uv = (mesh.uv * 2.0 - 1.0) * 1.0;
    uv *= 2.0;
    
    // 动起来
    W *= abs(sin(my_material.time * 0.1));
    H *= abs(cos(my_material.time * 0.1 + 1.0));
    X = sin(my_material.time);
    Y = cos(my_material.time) * 0.6;
    ROTATE = sin(my_material.time * 0.2) * 3.14;
    
    let samples = 128;
    var color = vec3(0.0);

    // 每个sample取一个随机方向
    for(var i = 0; i < samples; i++) {
        let t = (f32(i) + random(uv + f32(i) + my_material.time)) / f32(samples) * 6.283;

        var c = vec3(0.0);
        ray_march(uv, vec2(cos(t), sin(t)), &c);
        color += c;
    }

    color = color / f32(samples);

	return vec4(color, 1.0);
}

9. Bevy RenderGraph#

如果对webgpu不熟,先需要找本书把webgpu大概的结构过一遍。
对于如何画三角形,如何起pipeline,如何起shader这些东西混个眼熟。
可以直接找js版本的课程,非常方便。rust搞起来更费劲。

然后就要看bevy是怎么整合这些东西。

https://docs.rs/bevy_render/0.15.3/bevy_render/render_graph/struct.RenderGraph.html

一堆渲染node组成一个DAG,形成一个RenderGraph。
节点之间用edge连接,形成数据的传输。

render_graph::Node是基本的node,每帧都运行。

render_graph::ViewNode会针对每个view/camera来运行,适用于后处理。

render模块和游戏世界(ECS)是两个不同的app,两者之间需要一个传数据的机制,无法随意获取对方数据。


9.1 看代码#

研究bevy源码中custom_post_processing.rs这个例子。

  1. 起一个PostProcessPipeline struct。对其实现FromWorld的from_world,因为需要用ecs世界中的数据来初始化。
    这里大概就是走一遍图形api的常见初始化流程。只会跑一次。
    create_bind_group_layout
    create_sampler
    读取shader文件
    获取PipelineCache,bevy的pipeline容器。
    以现有的数据创建一个RenderPipelineDescriptor,包括pipeline的各种配置。
    我们不用显示创建pipeline,应该是bevy会在某个时间点创建。
    用queue_render_pipeline把这个RenderPipelineDescriptor插入PipelineCache,获取其id。
    一些有用的数据存在PostProcessPipeline中备用,例如创建的pipeline的id。

  2. 新起一个plugin,在plugin中获取RenderApp,把我们的PostProcessPipeline添加为资源。

  3. 起一个PostProcessNode struct作为node。对其实现ViewNode。
    实现ViewQuery。每次run时会用这个获取ECS的数据。

  4. 实现run。每一帧run都会跑。
    可以获取PostProcessPipeline和PipelineCache。
    可以用PostProcessPipeline我们准备好的id从PipelineCache获取pipeline的实体数据。
    view_target.post_process_write获取后处理通道。
    RenderContext包含render的主要数据例如device。
    create_bind_group做一些数据的绑定。
    begin_tracked_render_pass开始render pass。
    set_render_pipeline设置pipeline实体数据。
    最后draw。

  5. plugin中用add_render_graph_node把PostProcessNode插入RenderGraph。
    add_render_graph_edges创建edge。

再看compute_shader_game_of_life.rs这个例子,差不多的结构。


10. sdf全局光照#

看论文
https://arxiv.org/pdf/2007.14394

https://www.youtube.com/watch?v=iY15xhuuHPQ


11. bevy实现#

看这个项目的代码
zaycev/bevy-magic-light-2d
其光照的原理大概就是上面的论文应用到2d。

整体结构

  1. 创建普通的2d mesh,作为游戏中的物体。

  2. 创建对应的Occluder,作为光的遮挡物。

  3. 起一个camera,这个camera会显示正常的mesh。
    但是render target设置为一个独立的Image。
    这个Image在最后阶段和别的光照数据组合为最终的图像。

  4. 创建sdf的pipeline,配置各种数据。

  5. 在RenderApp的render阶段,把计算sdf所需的world数据更新到wgpu的各种buffer。

  6. 写sdf的compute shader,根据输入的数据计算sdf。

  7. sdf输出到一个Image叫sdf_target。

  8. 重复4-7,可以创建多个pass,实现各种光照流程。

  9. 在bind group的设置中,可以通过共用的Image把各个pass的输入输出串起来。

  10. 实现一个render_graph::Node,在run()中设置和执行各个pass。

  11. 把Node插入RenderGraph,放到CameraDriverLabel后面。

  12. 起一个PPMaterial,里边包括所有之前阶段的结果Image。

  13. 写PPMaterial的shader,整合之前阶段的结果。

  14. 起一个quad承载最终的画面,使用PPMaterial。

  15. 起一个普通的Camera2d,显示这个quad。

probe的方式

  1. 例如图像大小为512x512=262144个点

  2. compute shader中统一@compute @workgroup_size(8, 8, 1)。即每一组算64个点。

  3. 对于sdf,可以算所有点,或者算256x256之类。

  4. 对于光的probe,把图像分成例如8x8像素的格子。每个格子作为probe点。
    dispatch_workgroups的xy参数传8x8。
    这样一共计算8x8x8x8=4096个点,计算量是原始512x512的1/64。

shader中的光的计算步骤
感觉跟光子映射非常相似!

  1. 算点的sdf值,是一个f32。

  2. 对于每个probe点,遍历所有光源,用raymarching检查是否被阻挡。把所有直接光贡献加起来。结果存为一个texture。

  3. 对于每个probe点,计算间接光(反弹的光)。规定一个最大反射距离,用raymarching采样多个反射距离之内的表面。把交点处对应的直接光,根据距离和其他配置处理一下,做成间接光。把直接光和间接光加起来存为一个texture,输入到下一步。

  4. blend。大概是记录每一帧的camera位置。与相机的老位置的光照做一些blend?达到一种延迟和柔和的效果?如果不做这个事情,会出现不柔和和闪烁卡顿,具体待研究。

  5. filter。这里计算所有512x512。进行光的smooth,根据附近的probe点进行插值。
    如果不做,可以看出光是一格一格的,不是均匀的。
    但如果是像素风格,其实不算很大的影响。

  6. 后处理。组合正常的mesh颜色和光照。

texture的格式有说法。
R16Float在shader中存vec4的话只能存入第一个值,比较坑。
存颜色要用Rgba16Float之类。


花时间看懂bevy-magic-light-2d这个项目的基本原理。
之后就可以按需山寨一个自己的全局光照了。