前端与GPU之间的距离

2,077 阅读9分钟

前言

承接上一篇《关于梳理封装Threejs工具类这档事》,作为前端其实也是时不时会与GPU这个名词有接触的,但有没有感觉那距离是这么近,又那么远。这篇主要是围绕着做3D效果都离不开的着色器去介绍一下这之间的关系。

下面这几个有意思的问题,或许看完这篇之后这些疑问会得到解答。

  • 前端可以控制GPU的渲染吗?
  • 经常听到的GPU加速是什么呢?
  • 着色器是什么?
  • 特效编写为什么用着色器性能更好呢?

其中GPU加速也是平时能遇到的,使用CSS的某些属性就能触发GPU加速,如transform: translateZ(0)

接下来出现的陌生名词有点多,慢慢来,先从着色器开始出发。

渲染管线(Pipeline)与着色器(shader)

要了解着色器是什么,就要先了解一下什么是渲染管线。假设我们有一堆图形数据,渲染管线所做的工作就是将数据(点point 面face什么的)经过一系列处理后渲染到屏幕的过程。这也是显卡(GPU)的主要工作流程。而着色(shader)就是显卡工作流程当中相当重要的一环,并且这个过程还是可编辑的,我们也称之为可编程管线

我们以一个球体模型为例,这个模型对象包含了球体在空间中的位置信息和点组成的信息,渲染管线大概的流程可以这么走

球体的顶点与面数据输入->顶点着色器->图元组装->光栅化->片元着色器->混合测试->最终输出到设备。

image

在Threejs中着色器可以理解为给物体的顶点确定位置,给物体的面确定颜色的工具,介入的是渲染管线中顶点着色片元着色的处理过程。到这里已经对着色器是什么有个大概的印象了。接下来要介绍的是在Threejs中写动效的两种方式。

CPU渲染与GPU渲染

这事情要从GPU的诞生讲起,我们都知道屏幕的像素非常多,比如4K屏幕就是4096×21608百多万个像素。显然使用单线程的CPU串行去处理是非常不合适的,需要有一种能并行处理大量数据的设备,这样GPU就诞生了,比如GTX1060的流处理器数量就有1280个。

注意屏幕的像素之间并不知道对方的渲染的是啥,这一点在写着色器时思路要适应一下。

CPU渲染GPU渲染有什么区别?这是物体状态放在CPU中处理还是放在GPU中处理的区别,以Threejs中的粒子运动动画为例,如果使用CPU渲染,则需要在每一帧中用js中去维护粒子们的位置状态。这样做是完全没有问题的,问题主要是CPU是单线程的,性能会很有问题。如果使用GPU渲染,则是每一帧都在着色器中去维护粒子的位置状态,即用GPU去处理。

CPU渲染

image

// CPU渲染
const points = new THREE.Points(geometry, material);
function render(time){
    points.forEach(item=>{
        item.position.setX(100*Math.cos(time));
        item.position.setY(100*Math.sin(time));
    })
}
requestAnimationFrame(render)

GPU渲染

image

// GPU渲染
// 顶点着色器
// vertexShader.glsl
uniform float time;
void main(){
    vec3 uPosition;
    uPosition.x = 100.*cos(time);
    uPosition.y = 100.*sin(time);

    gl_PointSize = 4.;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(uPosition, 1.);
}

假如我们的有2万个粒子,可想而知,在CPU中串行处理这些顶点的位置信息要远比在GPU中并行处理这些信息要慢得多。而且着色器中的常用函数经过编译之后是能跟硬编码在GPU中的处理过程对应上的,所以处理数据的速度跟电子的流动速度差不多。这也是为什么我们讲硬件加速能够加速的原因。

GLSL语法

上面那一段类似于C语言的代码是由GLSL(Graphics Library Shading Language)写的着色器程序,具体语法可以之后去这里看GLSL中文手册,规则很简单,这里就不做语法的入门介绍了。具体的函数有什么效果和能做出什么图形会再后一篇《关于使用着色器内置函数与相关特效这档事》(草稿中)中讲到。

接下来看看着色器是怎么去编写的。

顶点着色器vertexShader与片元着色器fragmentShader

Threejs/webgl中,我们能够编写的着色器分别有顶点着色器片元着色器

顶点着色器可以用来处理每个顶点的位置,大小,颜色,纹理坐标等数据,而片元着色器则是用来处理光栅化后图元所覆盖区域的每一个像素。

普通使用shader

也是最常见的使用方式,在script标签中编写相关的着色器

// main.html
<script id="vertexShader" type="x-shader/x-vertex">
void main(){
    gl_PointSize = 4.;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
</script>
// main.html
<script id="fragmentShader" type="x-shader/x-fragment">
uniform vec3 color;
void main(){
    gl_FragColor = vec4(color , 1.0);
}
</script>

在对应的位置使用

// 使用
const vertexShader = document.getElementById('vertexShader').innerText;
const fragmentShader = document.getElementById('fragmentShader').innerText;

优雅使用shader

在webpack环境下,有raw-loader的支持。这样我们就可以把glsl放置在独立的文件下。

// vShader.glsl
void main(){
    gl_PointSize = 4.;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// fShader.glsl
uniform vec3 color;
void main(){
    gl_FragColor = vec4(color , 1.0);
}
const vShader = require('!raw-loader!./vShader.glsl').default;
const fShader = require('!raw-loader!./fShader.glsl').default;

更多模块化使用

github.com/glslify/gls…

Threejs到shader的数据传递

image

首先这个数据传递过程是单向的,着色器代码并不会提供事件回调方法(屏幕上的像素能提供事件回调才叫怪事),在Threejs中,创建材质时,会默认将物体的位置,法线,贴图等信息传入到顶点着色器。变量可以通过uniforms/attribute两个途径传入。

uniforms是在创建着色器时传入的,然后在着色器中使用相同命名的变量即可。attribute则是物体geometry的属性,并不在创建着色器时传入,而是在创建Mesh时自动传入。

接下来看看uniformsattribute具体是如何传入的

// ...
// 使用uniform中的默认颜色
const material = new THREE.ShaderMaterial({
	uniforms: {
		uColor: {
			value: new THREE.Color(0x338899),
		},
		test: {
		    value: 0.5,
		}
	},
	vertexShader: vShader,
	fragmentShader: fShader
});
// ...
// positionTest:重新定义的顶点位置
// const positionTest = new Float32Array(...);
geometry.setAttribute(
    "positionTest",
    new THREE.BufferAttribute(positionTest, 3)
);
// ...
// 顶点着色器
// 使用自定义顶点位置positionTest
attribute vec3 positionTest;
void main(){
    gl_Position = projectionMatrix * modelViewMatrix * vec4(positionTest, 1.0);
}
// 片元着色器
// 使用uniforms类型变量
uniforms vec3 uColor;
void main(){
    gl_FragColor = vec4(uColor , 1.0);
}

这样就可以使用自定义的顶点跟自定义的颜色值了。

image

着色器动效

image

那怎么让着色器渲染的画面动起来呢?方法有很多,动画与时间有关,我们可以在render时传入时间。也可以用Tween.js这个库生成传入一段时间变化。

requestAnimationFrame(callback),回调函数callback的第一个参数就是返回以毫秒为单位的时间。使用Tween.js,render时需要调用一下 TWEEN.update()。

上图的图形效果也很简单,是使用fractstep函数生成的。

// fragmentShader.glsl
varying vec2 vUv;
uniform float uVal;
uniform vec2 uResolution;

// 这里的处理是将uv坐标与物体尺寸对应起来
vec2 centerUv(vec2 uv,vec2 resolution){
    float aspect=resolution.x/resolution.y;
    uv.x*=aspect;
    return uv;
}

void main(){
    vec2 cUv = centerUv(vUv, uResolution);
    float dis = distance(cUv,vec2(0.5,0.5));
    
    gl_FragColor = vec4(vec3(step(0.05,fract(dis*10.0 - uVal))+uVal),1.0);
}

这里是用了Tween.js生成周期性的时间变化。

function crateMaterial(uResolution: THREE.Vector2): ShaderMaterial {
	const material = new THREE.ShaderMaterial({
		uniforms: {
			uVal: {
				value: 0,
			},
			uResolution: {
				value: uResolution,
			},
		},
		vertexShader: vShaderScale,
		fragmentShader: fShaderScale,
	});

	// 给着色器提供周期变化
	const pos = { uVal: 0 };
	const tween = new TWEEN.Tween(pos)
		.to({ uVal: 1 }, 2000)
		.easing(TWEEN.Easing.Quadratic.InOut)
		.onUpdate(function ({ uVal }) {
			material.uniforms.uVal.value = uVal;
		});
	const tweenBack = new TWEEN.Tween(pos)
		.to({ uVal: 0 }, 2000)
		.easing(TWEEN.Easing.Quadratic.InOut)
		.onUpdate(function ({ uVal }) {
			material.uniforms.uVal.value = uVal;
		});
	tween.chain(tweenBack);
	tweenBack.chain(tween);
	tween.start();
	return material;
}
threetool.continuousRender((time) => {
	TWEEN.update();
});

使用自定义着色器的场景

看到这里之后是不是觉得着色器很厉害,竟然可以自定义顶点跟片元的显示,而且性能相对CPU渲染又好。那在使用Threejs时是不是凡事都使用自定义着色器会更好呢?答案并不是这样的,看到这里可以再次反问一个问题,着色器是什么?回顾一下,着色器所做的工作是GPU渲染管线整个流程中的一环(或几环),所以要渲染一个图形是无论如何都绕不开着色器的。那没有自定义着色器的几何图形是怎么渲染出来的呢?答案是Threejs中的材质有自己的默认着色器,比如lambert材质的着色器,里面的顶点片元着色器就是根据漫反射光照模型写出来的。大多时候我们可以直接使用,减少不必要的重复开发。

image

可以看到,Threejs源码中有各种材质的着色器,这里对应着不同类型的material

借用Threejs内置着色器

先来看一下原生webgl使用着色器的过程,要使用着色器需要经过以下几个步骤

  • 编译着色器const shader = gl.compileShader(vertexShader);
  • 创建glsl程序对象const program = gl.createProgram();
  • 将着色器分配到glsl程序对象上gl.attachShader(program, shader);
  • 链接这个程序对象gl.linkProgram(program);
  • 设置程序对象有效gl.useProgram(program);

所以改写由Threejs提供的着色器时,需要在编译着色器之前做相关的操作。我们在Threejs提供的onBeforeCompile这个生命周期里去改写就ok了。

const material = new THREE.MeshBasicMaterial();

material.onBeforeCompile = (shader) => {
	// replace some code to do something
	// shader.vertexShader = xxx;
	// shader.fragmentShader = xxx;
	return shader;
};

这里是一些题外话,在看Three的着色器源码时是不是很疑惑,居然还能使用#include进行模块化,这是怎么做到的呢?

image

再仔细一看,可以发现下面这一段正则表达式,其实是使用前用replace将对应的着色器字符串替换过来了,或许这就是模块化的最原始实现吧。

image

结尾

下一篇会以这两篇的内容为基础,写一个特效max的粒子变换插件《关于写一个粒子效果变换插件这档事》。

image

源码

原创不易,转载请联系作者。大伙要不点个赞意思意思,点赞是作者开源代码继续更新的动力。这里补上上一篇讲到和本篇可能会用上的Threejs工具类代码,上一篇的Demo也在里面,下一篇的插件会继承这个工具类。

准备更新的系列

  • 《关于梳理封装Threejs工具类这档事》
  • 《关于写一个粒子效果变换插件这档事》(最后整理中)
  • 《关于使用着色器内置函数与相关特效这档事》(草稿整理中)
    • 各种函数及特效
  • 《关于着色器光照效果这档事》(草稿中)
    • 逐平面着色(Flat着色)
    • 逐顶点着色(Gouraud着色)
    • 逐像素着色(Phong着色)
    • BlinnPhong光照模型
    • 菲涅尔效应
    • 卡通着色
    • image
  • 《关于局部坐标世界坐标投影坐标这档事》(草稿中)
    • 局部坐标
    • 世界坐标
    • 投影坐标
    • 矩阵变换
  • 《关于github首页地球特效这档事》
  • 《关于D3js这档事》
  • 《关于一个数据关系图可视化这档事》
  • 《关于写一个跳一跳小游戏这档事》
    • 场景生成
    • 碰撞检测
    • 游戏逻辑