几年前跟朋友一起开发Unity3d游戏,为了能达到很好的效果尝试过各种着色器,当时就总在想如果能自己改写shader就好了。最近研究WebGL,发现如果想要定制一些特殊的视觉效果,也离不开shader技术。shader的编写有一定门槛和难度。这篇文章翻译自html5rocks,虽然是很早之前的一篇文章,但是看了不少介绍shader的技术文章,还是感觉这篇讲的最易懂,是一篇章入门级的好文,不用很懂WebGLS,但需要有点Three.js基础。shader的正规翻译叫「着色器」,只是我感觉这么翻译总是不够达意,所以译文通篇对shader都不作翻译保留原词。下面是原文部分:
笔者之前介绍过Three.js。如果没看过的话可以看看,这篇文章离不开Three.js。
Three.js把很多WebGL中的高难度问题抽象出来变成了一个简单易用的库。但我们有时候禁不住还是想做一些特殊的效果,看看能在屏幕上做出点什么更酷炫的东西。这就需要用到Shader了。
WebGL并非固定渲染管线,它采用的是一种精简的方式,我们没有太多现成的方法可以随意使用。然而它的管线是可编程的,这种实现方式门槛更高,但是也更强大。面对可编程管线,程序员需要负责从顶点到渲染的一切环节。Shader技术就是这条管线中的一个环节。
Shader分为两种:
1. 顶点vertex shader
2. 片段fragment shader
两种shader跟他们的名字其实没什么关系,它们都是运行在显卡的GPU上。现代GPU对shader所常用的功能做了大量优化,所以我们可以完全地把shader的功能交给GPU,CPU则负责其他的功能。
标准几何形体都是由顶点构成的。同时每一个顶点都会赋予一个顶点shader。顶点shader可以对顶点做很多事情,但它最重要的使命就是通过一个叫gl_Position
的4D浮点向量来定义顶点最终在屏幕上的位置。这事实上是一个把3D位置投影成2D坐标的过程,其实现原理深究起来可以讲很多东西,但幸亏有Three.js我们可以忽略掉那些高难度操作直接设置gl_Position
。
一个物体有了顶点,也能投射到2D屏幕上,那么它应该是什么颜色?材质和灯光是否会对它产生影响?这就是片段shader所关心的。
片段shader也有一项最关键的使命:通过一个叫gl_FragColor
的4D浮点向量定义顶点颜色。那它为什么叫「片段」shader?因为三个点围成一个三角形,落在三角形中的像素都需要被绘制出来,所谓片段指的就是被三个顶点各自颜色所影响的三角形里的像素信息。片段shader是以插值的方式来获取颜色数据,比方说一个点是红色的,一个点是蓝色的,那么片段shader生成的颜色信息就是从红色到紫色再到蓝色。
在shader中,你可以声明三种变量:Uniforms
、Attributes
和Varyings
。最开始听到这三个名字的时候,笔者其实有点懵,因为此前的工作笔者从来都没接触过这三个东西。笔者认为你可以这么去理解它们:
介绍完两种shader以及三种变量,我们可以试试看做一个最基本的shader。
下面是一个简单的顶点shader:
/**
* Multiply each vertex by the model-view matrix
* and the projection matrix (both provided by
* Three.js) to get a final vertex position
*/
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
}
下面是一个简单的片段shader:
/**
* Set the colour to a lovely pink.
* Note that the color is a 4D Float
* Vector, R,G,B and A and each part
* runs from 0.0 to 1.0
*/
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}
就这些。
在顶点shader部分,我们通过Three.js传了两个uniforms变量。这两个变量一个叫模型-视图矩阵,一个叫投影矩阵,都是4D矩阵。你不用太深究它们的工作原理,当然能了解一下更好了。反正它们相乘在一起就可以把一个3D位置投射成2D位置。
Three.js已经把上面的两段小代码附加在它的shader中,你编写shader的时候就不用再去操心了。实际上Three.js默认附带的远不止这些,还有比如光照信息、顶点颜色以及顶点法线等。如果你不用Three.js的话就要自己写一大堆的uniforms和attributes,真的。
前面写好了shader,接下来如何在Three.js中使用呢?很简单:
/**
* Assume we have jQuery to hand and pull out
* from the DOM the two snippets of text for
* each of our shaders
*/
var shaderMaterial = new THREE.MeshShaderMaterial({
vertexShader: $('vertexshader').text(),
fragmentShader: $('fragmentshader').text()
});
通过这样的写法,Three.js就能把你写的shader赋给相应的面片材质。运行一下,会得到下面的结果:
实际上我们可以给MeshShaderMaterial
再添加uniforms和attributes这两个属性。这两个属性的值可以是向量、整型或浮点。Uniforms在一帧中都保持不变,可以为一个单一数值。Attributes是给到每个顶点的,所以最好是数组。Attributes数组中的值与顶点数组中的值应该是一一对应的关系。
下面我们要花点时间做一个动画循环,这将会用到attributes、uniform两个类型,外加一个varying变量好让顶点shader可以给片段shader发送数据。之前的粉色球会变得像被从上面点亮了一样,侧面还有脉冲的感觉。通过这个例子你一定能对三种类型的变量使用有更深入的理解,尽管最终效果有那么点迷幻。
我们改变一下着色方式,让它看起来不那么扁平。正常来讲我会打一盏Three.js中的灯,但是现在谈灯光还有点早,所以我会模拟一个灯光出来。
这回我们改进一下顶点shader,让每个顶点都能从片段shader获取一个法线值。我们通过Varying来实现:
// create a shared variable for the
// VS and FS containing the normal
varying vec3 vNormal;
void main() {
// set the vNormal value with
// the attribute value passed
// in by Three.js
vNormal = normal;
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(position,1.0);
}
在另一边的片段shader中,我们定义了一个相同的变量名vNormal
,也就是顶点shader中的法向量。同时我们还准备了一个可以模拟光照的向量light
,接下来我们让顶点法向量与光照模拟向量做点乘,计算的结果就近似模拟了一个平行光的照射效果。
// same name and type as VS
varying vec3 vNormal;
void main() {
// calc the dot product and clamp
// 0 -> 1 rather than -1 -> 1
vec3 light = vec3(0.5,0.2,1.0);
// ensure it's normalized
light = normalize(light);
// calculate the dot product of
// the light to the vertex normal
float dProd = max(0.0, dot(vNormal, light));
// feed into our frag colour
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);
}
运行的结果是这样的:
之所以使用向量点乘,是由于点乘所得到的结果某种程度上就代表两个向量之间的「近似程度」。对于两个单位向量,如果它们方向一致,点乘结果就是1,如果方向相反,结果是-1。我们把刚刚的计算用在光照上,那么右上方的顶点的值约等于1,相当于完全照亮,另一侧的光照则近乎于0,背面则为-1。负数则转为0。
接下来我们通过attribute给每个顶点一个随机的值,顶点将以该值沿着法线向外凸出。
下面是代码:
attribute float displacement;
varying vec3 vNormal;
void main() {
vNormal = normal;
// push the displacement into the three
// slots of a 3D vector so it can be
// used in operations with other 3D
// vectors like positions and normals
vec3 newPosition = position +
normal *
vec3(displacement);
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(newPosition,1.0);
}
运行一下发现似乎没有任何变化,这是因为attribute还没有在MeshShaderMaterial中创建,shader也就没管太多直接用一个0值代替了,有点像占位符的作用。下一步我会在Three.js中给MeshShaderMaterial添加attribute,Three.js会自动地帮我们把两者结合起来。
值得注意一下,我之所以把newPosition赋值给一个新建的vec3变量,是因为所有的attribute属性是只读的。
attribute是与每个顶点一一对应的,所以我们对每个顶点都使用attribute。代码如下:
var attributes = {
displacement: {
type: 'f', // a float
value: [] // an empty array
}
};
// create the material and now
// include the attributes property
var shaderMaterial = new THREE.MeshShaderMaterial({
attributes: attributes,
vertexShader: $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});
// now populate the array of attributes
var vertices = sphere.geometry.vertices;
var values = attributes.displacement.value
for(var v = 0; v < vertices.length; v++) {
values.push(Math.random() * 30);
}
运行一下,球体看起来是这样的:
虽然球看起来就像被削了一样难看,但好消息是所有这些顶点的置换变换都是在GPU上运行的。
我们应该让它动起来,为此需要实现两点:
我们会在MeshShaderMaterial和顶点Shader中同时添加uniform。
首先是顶点shader:
uniform float amplitude;
attribute float displacement;
varying vec3 vNormal;
void main() {
vNormal = normal;
// multiply our displacement by the
// amplitude. The amp will get animated
// so we'll have animated displacement
vec3 newPosition = position +
normal *
vec3(displacement *
amplitude);
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(newPosition,1.0);
}
然后是MeshShaderMaterial:
// add a uniform for the amplitude
var uniforms = {
amplitude: {
type: 'f', // a float
value: 0
}
};
// create the final material
var shaderMaterial = new THREE.MeshShaderMaterial({
uniforms: uniforms,
attributes: attributes,
vertexShader: $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});
完事后运行一下,画面是这样的:
由于amplituede
的值为0,所以看不出有什么变化。这时我们在JS中用requestAnimationFrame
来创建动画,uniform值的变化也是在这个方法中实现。
var frame = 0;
function update() {
// update the amplitude based on
// the frame value
uniforms.amplitude.value = Math.sin(frame);
frame += 0.1;
renderer.render(scene, camera);
// set up the next call
requestAnimFrame(update);
}
requestAnimFrame(update);
终于这个丑陋的小球开始像心脏一样跳动了。
这篇文章仅仅是个介绍,希望你能理解shader的原理,并做一些自己的酷炫shader出来!