你好,我是月影。
前面几节课,我们学习了利用向量和矩阵公式,来处理像素和生成纹理的技巧,但是这些技巧都有一定的局限性:每个像素是彼此独立的,不能共享信息。
为什么这么说呢?因为GPU是并行渲染的,所以在着色器的执行中,每个像素的着色都是同时进行的。这样一来,我们就不能获得某一个像素坐标周围坐标点的颜色信息,也不能获得要渲染图像的全局信息。
这会导致什么问题呢?如果我们要实现与周围像素点联动的效果,比如给生成的纹理添加平滑效果滤镜,就不能直接通过着色器的运算来实现了。
因此,在WebGL中,像这样不能直接通过着色器运算来实现的效果,我们需要使用其他的办法来实现,其中一种办法就是使用后期处理通道。所谓后期处理通道,是指将渲染出来的图像作为纹理输入给新着色器处理,是一种二次加工的手段。这么一来,虽然我们不能从当前渲染中获取周围的像素信息,却可以从纹理中获取任意uv坐标下的像素信息,也就相当于可以获取任意位置的像素信息了。
使用后期处理通道的一般过程是,我们先正常地将数据送入缓冲区,然后执行WebGLProgram。只不过,在执行了WebGLProgram之后,我们要将输出的结果再作为纹理,送入另一个WebGLProgram进行处理,这个过程可以进行一次,也可以循环多次。最后,经过两次WebGLProgram处理之后,我们再输出结果。
你可以先仔细看看这张流程总结图,加深一下印象。接下来,我会结合这个过程,说说怎么用后期处理通道,来实现Blur滤镜、辉光效果和烟雾效果,这样你就能理解得更深刻了。
首先,我们来实现Blur滤镜。
其实在第11节课中,我们已经在Canvas2D中实现了Bblur滤镜(高斯模糊的平滑效果滤镜),但Canvas2D实现滤镜的性能不佳,尤其是在图片较大,需要大量计算的时候。
而在WebGL中,我们可以通过后期处理来实现高性能的Blur滤镜。下面,我就以给随机三角形图案加Blur滤镜为例,来说说具体的操作。
首先,我们实现一个绘制随机三角形图案的着色器。代码如下:
#ifdef GL_ES
precision highp float;
#endif
float line_distance(in vec2 st, in vec2 a, in vec2 b) {
vec3 ab = vec3(b - a, 0);
vec3 p = vec3(st - a, 0);
float l = length(ab);
return cross(p, normalize(ab)).z;
}
float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
vec3 ab = vec3(b - a, 0);
vec3 p = vec3(st - a, 0);
float l = length(ab);
float d = abs(cross(p, normalize(ab)).z);
float proj = dot(p, ab) / l;
if(proj >= 0.0 && proj <= l) return d;
return min(distance(st, a), distance(st, b));
}
float triangle_distance(in vec2 st, in vec2 a, in vec2 b, in vec2 c) {
float d1 = line_distance(st, a, b);
float d2 = line_distance(st, b, c);
float d3 = line_distance(st, c, a);
if(d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 || d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0) {
return -min(abs(d1), min(abs(d2), abs(d3))); // 内部距离为负
}
return min(seg_distance(st, a, b), min(seg_distance(st, b, c), seg_distance(st, c, a))); // 外部为正
}
float random (vec2 st) {
return fract(sin(dot(st.xy,
vec2(12.9898,78.233)))*
43758.5453123);
}
vec3 hsb2rgb(vec3 c){
vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0);
rgb = rgb * rgb * (3.0 - 2.0 * rgb);
return c.z * mix(vec3(1.0), rgb, c.y);
}
varying vec2 vUv;
void main() {
vec2 st = vUv;
st *= 10.0;
vec2 i_st = floor(st);
vec2 f_st = 2.0 * fract(st) - vec2(1);
float r = random(i_st);
float sign = 2.0 * step(0.5, r) - 1.0;
float d = triangle_distance(f_st, vec2(-1), vec2(1), sign * vec2(1, -1));
gl_FragColor.rgb = (smoothstep(-0.85, -0.8, d) - smoothstep(0.0, 0.05, d)) * hsb2rgb(vec3(r + 1.2, 0.5, r));
gl_FragColor.a = 1.0;
}
这个着色器绘制出的效果如下图:
接着就是重点了,我们要使用后期处理通道对它进行高斯模糊。
首先,我们需要准备另一个着色器:blurFragment。通过它,我们能将第一次渲染后生成的纹理tMap内容给显示出来。
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
uniform sampler2D tMap;
void main() {
vec4 color = texture2D(tMap, vUv);
gl_FragColor.rgb = color.rgb;
gl_FragColor.a = color.a;
}
然后,我们要修改JavaScript代码,把渲染分为两次。第一次渲染时,我们启用program程序,但不直接把图形输出到画布上,而是输出到一个帧缓冲对象(Frame Buffer Object)上。第二次渲染时,我们再启用blurProgram程序,将第一次渲染完成的纹理(fbo.texture)作为blurFragment的tMap变量,这次的输出绘制到画布上。代码如下:
...
renderer.useProgram(program);
renderer.setMeshData([{
positions: [
[-1, -1],
[-1, 1],
[1, 1],
[1, -1],
],
attributes: {
uv: [
[0, 0],
[0, 1],
[1, 1],
[1, 0],
],
},
cells: [[0, 1, 2], [2, 0, 3]],
}]);
const fbo = renderer.createFBO();
renderer.bindFBO(fbo);
renderer.render();
renderer.bindFBO(null);
const blurProgram = renderer.compileSync(blurFragment, vertex);
renderer.useProgram(blurProgram);
renderer.setMeshData(program.meshData);
renderer.uniforms.tMap = fbo.texture;
renderer.render();
其中,renderer.createFBO是创建帧缓冲对象,bindFBO是绑定帧缓冲对象。为了方便调用,我在这里通过gl-renderer做了一层简单的封装。
那经过两次渲染之后,我们运行程序输出的结果和之前输出的并不会有什么区别。因为第二次渲染只不过是将第一次渲染到帧缓冲的结果原封不动地输出到画布上了。
接下来,我们修改blurFragment的代码,在其中添加高斯模糊的代码。代码如下:
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
uniform sampler2D tMap;
uniform int axis;
void main() {
vec4 color = texture2D(tMap, vUv);
// 高斯矩阵的权重值
float weight[5];
weight[0] = 0.227027;
weight[1] = 0.1945946;
weight[2] = 0.1216216;
weight[3] = 0.054054;
weight[4] = 0.016216;
// 每一个相邻像素的坐标间隔,这里的512可以用实际的Canvas像素宽代替
float tex_offset = 1.0 / 512.0;
vec3 result = color.rgb;
result *= weight[0];
for(int i = 1; i < 5; ++i) {
float f = float(i);
if(axis == 0) { // x轴的高斯模糊
result += texture2D(tMap, vUv + vec2(tex_offset * f, 0.0)).rgb * weight[i];
result += texture2D(tMap, vUv - vec2(tex_offset * f, 0.0)).rgb * weight[i];
} else { // y轴的高斯模糊
result += texture2D(tMap, vUv + vec2(0.0, tex_offset * f)).rgb * weight[i];
result += texture2D(tMap, vUv - vec2(0.0, tex_offset * f)).rgb * weight[i];
}
}
gl_FragColor.rgb = result.rgb;
gl_FragColor.a = color.a;
}
因为高斯模糊有两个方向,x和y方向,所以我们至少要执行两次渲染,一次对x轴,另一次对y轴。如果想要达到更好的效果,我们还可以执行多次渲染。
那我们就以分别对x轴和y轴执行2次渲染为例,修改后的JavaScript代码如下:
// 创建两个FBO对象交替使用
const fbo1 = renderer.createFBO();
const fbo2 = renderer.createFBO();
// 第一次,渲染原始图形
renderer.bindFBO(fbo1);
renderer.render();
// 第二次,对x轴高斯模糊
renderer.useProgram(blurProgram);
renderer.setMeshData(program.meshData);
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = fbo1.texture;
renderer.uniforms.axis = 0;
renderer.render();
// 第三次,对y轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo1);
renderer.uniforms.tMap = fbo2.texture;
renderer.uniforms.axis = 1;
renderer.render();
// 第四次,对x轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = fbo1.texture;
renderer.uniforms.axis = 0;
renderer.render();
// 第五次,对y轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(null);
renderer.uniforms.tMap = fbo2.texture;
renderer.uniforms.axis = 1;
renderer.render();
在上面的代码中,我们创建了两个FBO对象,然后将它们交替使用。我们一共进行5次绘制,先对原始图片执行1次渲染,再进行4次后期处理。
这里啊,我还要告诉你一个小技巧。本来啊,在执行的这5次绘制中,前四次都是输出到帧缓冲对象,所以我们至少需要4个FBO对象。但是,由于我们可以交替使用FBO对象,也就是可以把用过的对象重复使用。因此,无论需要绘制多少次,我们都只要创建两个对象就可以,也就节约了内存。
最终,我们就能通过后期处理通道实现Blur滤镜,给三角形图案加上模糊的效果了。渲染结果如下:
在上面这个例子中,我们是对所有元素进行高斯模糊的。那除此之外,我们还可以对特定元素进行高斯模糊。在可视化和游戏开发中,就常用这种技巧来实现元素的“辉光”效果。比如,下面这张图就是用辉光效果实现的浩瀚宇宙背景。
那类似这样的辉光效果该怎么实现呢?我们可以在前面添加高斯模糊例子的基础上进行修改。首先,我们给blurFragment加了一个关于亮度的滤镜,将颜色亮度大于filter值的三角形过滤出来添加高斯模糊。修改后的代码如下。
uniform float filter;
void main() {
vec4 color = texture2D(tMap, vUv);
float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
brightness = step(filter, brightness);
// 高斯矩阵的权重值
float weight[5];
weight[0] = 0.227027;
weight[1] = 0.1945946;
weight[2] = 0.1216216;
weight[3] = 0.054054;
weight[4] = 0.016216;
// 每一个相邻像素的坐标间隔,这里的512可以用实际的Canvas像素宽代替
float tex_offset = 1.0 / 512.0;
vec3 result = color.rgb;
result *= weight[0];
for(int i = 1; i < 5; ++i) {
float f = float(i);
if(axis == 0) { // x轴的高斯模糊
result += texture2D(tMap, vUv + vec2(tex_offset * f, 0.0)).rgb * weight[i];
result += texture2D(tMap, vUv - vec2(tex_offset * f, 0.0)).rgb * weight[i];
} else { // y轴的高斯模糊
result += texture2D(tMap, vUv + vec2(0.0, tex_offset * f)).rgb * weight[i];
result += texture2D(tMap, vUv - vec2(0.0, tex_offset * f)).rgb * weight[i];
}
}
gl_FragColor.rgb = brightness * result.rgb;
gl_FragColor.a = color.a;
}
然后,我们再增加一个bloomFragment着色器,用来做最后的效果混合。这里,我们会用到一个叫做Tone Mapping(色调映射)的方法。这个方法就比较复杂了,在这里,你只要知道它可以将对比度过大的图像色调映射到合理的范围内就可以了,其他的内容你可以在课后看一下我给出的参考链接。
这个着色器的代码如下:
#ifdef GL_ES
precision highp float;
#endif
uniform sampler2D tMap;
uniform sampler2D tSource;
varying vec2 vUv;
void main() {
vec3 color = texture2D(tSource, vUv).rgb;
vec3 bloomColor = texture2D(tMap, vUv).rgb;
color += bloomColor;
// tone mapping
float exposure = 2.0;
float gamma = 1.3;
vec3 result = vec3(1.0) - exp(-color * exposure);
// also gamma correct while we're at it
if(length(bloomColor) > 0.0) {
result = pow(result, vec3(1.0 / gamma));
}
gl_FragColor.rgb = result;
gl_FragColor.a = 1.0;
}
最后,我们修改JavaScript渲染的逻辑,添加新的后期处理规则。这里,我们要使用三个FBO对象,因为第一个FBO对象在渲染原始图形之后,还要在混合效果时使用,后两个对象是用来交替使用完成高斯模糊的。最后,我们再将原始图形和高斯模糊的结果进行效果混合就可以了。修改后的代码如下:
// 创建三个FBO对象,fbo1和fbo2交替使用
const fbo0 = renderer.createFBO();
const fbo1 = renderer.createFBO();
const fbo2 = renderer.createFBO();
// 第一次,渲染原始图形
renderer.bindFBO(fbo0);
renderer.render();
// 第二次,对x轴高斯模糊
renderer.useProgram(blurProgram);
renderer.setMeshData(program.meshData);
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = fbo0.texture;
renderer.uniforms.axis = 0;
renderer.uniforms.filter = 0.7;
renderer.render();
// 第三次,对y轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo1);
renderer.uniforms.tMap = fbo2.texture;
renderer.uniforms.axis = 1;
renderer.uniforms.filter = 0;
renderer.render();
// 第四次,对x轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = .texture;
renderer.uniforms.axis = 0;
renderer.uniforms.filter = 0;
renderer.render();
// 第五次,对y轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo1);
renderer.uniforms.tMap = fbo2.texture;
renderer.uniforms.axis = 1;
renderer.uniforms.filter = 0;
renderer.render();
// 第六次,叠加辉光
renderer.useProgram(bloomProgram);
renderer.setMeshData(program.meshData);
renderer.bindFBO(null);
renderer.uniforms.tSource = fbo0.texture;
renderer.uniforms.tMap = fbo1.texture;
renderer.uniforms.axis = 1;
renderer.uniforms.filter = 0;
renderer.render();
这样渲染之后,我就能让三角形图案中几个比较亮的三角形,产生一种微微发光的效果。渲染效果如下:
这样,我们就实现了最终的局部辉光效果。实现它的关键,就是在高斯模糊原理的基础上,将局部高斯模糊的图像与原始图像叠加。
除了模糊和辉光效果之外,后期处理通道还经常用来实现烟雾效果。接下来,我们就实现一个小圆的烟雾效果。具体的实现过程主要分为两步:第一步和前面两个例子一样,我们通过创建一个shader,画出一个简单的圆。第二步,我们对这个圆进行后期处理,不过这次的处理方法就和实现辉光不同了。
下面,我们就一起来看。
首先,我们创建一个简单的shader,也就是使用距离场在画布上画一个圆。这个shader我们非常熟悉,具体的代码如下:
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
void main() {
vec2 st = vUv - vec2(0.5);
float d = length(st);
gl_FragColor.rgb = vec3(1.0 - smoothstep(0.05, 0.055, d));
gl_FragColor.a = 1.0;
}
接着,我们修改一下shader代码,增加uTime、tMap这两个变量,代码如下所示。其中,uTime用来控制图像随时间变化,而tMap是我们用来做后期处理的变量。
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
uniform sampler2D tMap;
uniform float uTime;
void main() {
vec3 smoke = vec3(0);
if(uTime <= 0.0) {
vec2 st = vUv - vec2(0.5);
float d = length(st);
smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
}
vec3 diffuse = texture2D(tMap, vUv).rgb;
gl_FragColor.rgb = diffuse + smoke;
gl_FragColor.a = 1.0;
}
然后,我们依然创建两个FBO,用它们交替进行绘制。最后,我们把绘制的内容输出到画布上。这里,我使用了一个if语句,根据绘制过程判断初始绘制还是后续的叠加过程,就能把着色器合并成一个。这样一来,不管是输出到画布还是FBO,我们使用同一个program就可以了。
const fbo = {
readFBO: renderer.createFBO(),
writeFBO: renderer.createFBO(),
get texture() {
return this.readFBO.texture;
},
swap() {
const tmp = this.writeFBO;
this.writeFBO = this.readFBO;
this.readFBO = tmp;
},
};
function update(t) {
// 输出到画布
renderer.bindFBO(null);
renderer.uniforms.uTime = t / 1000;
renderer.uniforms.tMap = fbo.texture;
renderer.render();
// 同时输出到FBO
renderer.bindFBO(fbo.writeFBO);
renderer.uniforms.tMap = fbo.texture;
// 交换读写缓冲以便下一次写入
fbo.swap();
renderer.render();
requestAnimationFrame(update);
}
update(0);
你会发现,上面的代码执行以后输出的画面并没有什么变化。这是为什么呢?因为我们第一次渲染时,也就是当uTime为0的时候,我们直接画了一个圆。而当我们从上一次绘制的纹理中获取信息,重新渲染时,因为每次获取的纹理图案都是不变的,所以现在的画面依然是静止的圆。
如果我们想让这个图动起来,比如说让它向上升,那么我们只要在每次绘制的时候,改变一下采样的y坐标,就是每次从tMap取样时取当前纹理坐标稍微下方一点的像素点就可以了。具体的操作代码如下:
void main() {
vec3 smoke = vec3(0);
if(uTime <= 0.0) {
vec2 st = vUv - vec2(0.5);
float d = length(st);
smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
}
vec2 st = vUv;
st.y -= 0.001;
vec3 diffuse = texture2D(tMap, st).rgb;
gl_FragColor.rgb = diffuse + smoke;
gl_FragColor.a = 1.0;
}
不过,由于纹理采样精度的问题,我们得到的上升圆还会有一个扩散的效果。不过这没有关系,它不影响我们接下来要实现的烟雾效果。
接下来,我们需要构建一个烟雾的扩散模型,也就是以某个像素位置以及周边像素的纹理颜色来计算新的颜色值。为了方便你理解,我就以一个5*5的画布为例来详细说说。假设,这个画布只有中心五个位置的颜色是纯白(1.0),周围都是黑色,如下图所示。
在这个扩散模型中,每个格子到下一时刻的颜色变化量,等于它周围四个格子的颜色值之和减去它自身颜色值的4倍,乘以扩散系数。
假设扩散系数是常量0.1,那么第一轮每一格的颜色值我在表格上标出来了,如下图所示。
可以看到,上图中有三种颜色的格子,分别是红色、蓝色和绿色。下面,我们直接来看颜色值的计算过程。
首先是中间红色的那个格子。因为它四周的格子颜色都是1.0,所以它的颜色变化量是:0.1 * ((1.0 + 1.0 + 1.0 + 1.0) - 4 * 1.0) = 0,那么下一帧的颜色值还是1.0不变。
其次,红格子周围的四个蓝色格子。它们下一帧的颜色变化量为:0.1 * ((1.0 + 0 + 0 + 0)- 4 * 1.0) = -0.3,那么它们下一帧的颜色值都要减去0.3 就是 0.7。
最后,在计算绿色格子下一帧的颜色值时,要分为两种情况。
第一种,当要计算的绿色格子和两个蓝色格子相邻的时候,颜色变化量为:0.1 * ((1.0 + 1.0 + 0 + 0) - 4 * 0) = 0.2,所以绿格子下一帧的颜色值变为0.2。
第二种,当这个绿色格子只和一个蓝色格子相邻的时候,颜色变化量为0.1,那么绿格子下一帧的颜色值就变为0.1。
就这样,我们把每一帧颜色按照这个规则不断迭代下去,就能得到一个烟雾扩散效果了。那我们下一步就是把它实现到Shader中,不过,在Fragment Shader中添加扩散模型的时候,为了让这个烟雾效果,能上升得更明显,我稍稍修改了一下扩散公式的权重,让它向上的幅度比较大。
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
uniform sampler2D tMap;
uniform float uTime;
void main() {
vec3 smoke = vec3(0);
if(uTime <= 0.0) {
vec2 st = vUv - vec2(0.5);
float d = length(st);
smoke = vec3(step(d, 0.05));
// smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
}
vec2 st = vUv;
float offset = 1.0 / 256.0;
vec3 diffuse = texture2D(tMap, st).rgb;
vec4 left = texture2D(tMap, st + vec2(-offset, 0.0));
vec4 right = texture2D(tMap, st + vec2(offset, 0.0));
vec4 up = texture2D(tMap, st + vec2(0.0, -offset));
vec4 down = texture2D(tMap, st + vec2(0.0, offset));
float diff = 8.0 * 0.016 * (
left.r +
right.r +
down.r +
2.0 * up.r -
5.0 * diffuse.r
);
gl_FragColor.rgb = (diffuse + diff) + smoke;
gl_FragColor.a = 1.0;
}
你会发现,这个效果还不是特别真实。那为了达到更真实的烟雾效果,我们还可以在扩散函数上增加一些噪声,代码如下:
void main() {
vec3 smoke = vec3(0);
if(uTime <= 0.0) {
vec2 st = vUv - vec2(0.5);
float d = length(st);
smoke = vec3(step(d, 0.05));
// smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
}
vec2 st = vUv;
float offset = 1.0 / 256.0;
vec3 diffuse = texture2D(tMap, st).rgb;
vec4 left = texture2D(tMap, st + vec2(-offset, 0.0));
vec4 right = texture2D(tMap, st + vec2(offset, 0.0));
vec4 up = texture2D(tMap, st + vec2(0.0, -offset));
vec4 down = texture2D(tMap, st + vec2(0.0, offset));
float rand = noise(st + 5.0 * uTime);
float diff = 8.0 * 0.016 * (
(1.0 + rand) * left.r +
(1.0 - rand) * right.r +
down.r +
2.0 * up.r -
5.0 * diffuse.r
);
gl_FragColor.rgb = (diffuse + diff) + smoke;
gl_FragColor.a = 1.0;
}
这样,我们最终实现的效果看起来就会更真实一些:
今天,我们学习了怎么在WebGL中,使用后期处理通道来增强图像的视觉效果。我们讲了两个比较常用的效果,一个是Blur滤镜以及基于它实现辉光效果,另一个是烟雾效果。
那后期处理通道实现这些效果的核心原理其实都是一样的,都是把第一次渲染后的内容输出到帧缓冲对象FBO中,然后把这个对象的内容作为纹理图片,再进行下一次渲染,这个渲染的过程可以重复若干次。最后,我们再把结果输出到屏幕上,就可以了。
到这里,视觉基础篇的内容,我们就全部讲完了。在这个模块里,我们围绕处理图像的细节,系统地学习了怎么表示颜色,怎么生成重复图案,怎么构造和使用滤镜处理图像,怎么进行纹理造型,还有怎么使用不同的坐标系绘图,怎么使用噪声和网格噪声生成复杂纹理、以及今天学习的怎么使用后期处理通道增强图像。我把核心的内容总结成了一张脑图,你可以借助它,来复习巩固这一模块的内容,查缺补漏。
第一题,今天,我们实现的烟雾扩散效果还不够完善,也不够有趣,你能试着改进它,让它变得更有趣吗?你可以参考我给出的两个建议,也可以试试其他的效果。
1.给定一个向量,表示风向和风速,让烟雾随着这个风扩散,这个风可以随着时间慢慢变化(你可以使用前面学过的噪声来实现风的变化),看看能够做出什么效果。
2.尝试让烟雾跟随着鼠标移动轨迹扩散,达到类似下面的效果。
如果你觉得难以实现,这里有一篇文章详细讲了烟雾生成的方法,你可以仔细研究一下。
第二题,实际上,后期处理通道可不止是实现Blur滤镜、辉光或者烟雾效果那么简单,它还可以实现很多不同的功能。比如,SpriteJS官网上那种类似于探照灯的效果,就是用后期处理通道实现的。你可以用gl-renderer来实现这类效果吗?
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见!
完整的示例代码见GitHub仓库
[1] 原始FBO对象的创建和使用方法
[2] How to Write a Smoke Shader
评论