手把手带你入门 Three.js Shader 系列(一)

插播一条在朋友圈、群里早发过但公众号还没发的“最新”动态:五一前后古柳总算换了能看到 AR 的手机,基于一年前的 demo 用 Three.js + Shader 等做了个 AR 简单效果,欢迎大家用手机 Chrome 访问 https://desertsx.github.io/webxr 体验下并分享录屏效果,每次配色都会不同,在自己身边显示出增强现实效果还是很酷的。当然有些手机可能不支持,出现 not supported 就是不行,注意得用手机 Chrome 访问。具体效果可参见原文里的视频:手把手带你入门 Three.js Shader 系列(一) - 牛衣古柳

说多了都是泪

离上篇文章发布又快过去一个月,这篇文章古柳写起来也是很难受,感觉很多东西讲起来很繁琐,自己讲不清楚,一直不擅长讲解原理和基础的细节,有时候真想直接放一些文章和视频,让大家自己去看,不做解释,但感觉那样对于这个系列的教程而言就有所缺失,所以终究还是边痛苦地尝试解释,边艰难地着一步步完成内容,虽然最终内容可能仍不完美,但好歹先完成再说。也希望大家点赞、转发、打赏、评论等多多支持。另外放了一些链接可供大家参考学习,希望能对大家有所帮助。

前置要求

在正式带大家走进 Three.js Shader 世界之前,需要大家对 Three.js 有最基本的了解,能看懂下面的代码,知道如何在 3D 里放一个纯色的平面物体即可。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js Shader</title>
    <style>
        body {
            margin: 0;
        }
    </style>
</head>

<body>
    <script type="module">
        import * as THREE from 'https://unpkg.com/three@0.152.2/build/three.module.js';

        // Scene
        const scene = new THREE.Scene();

        // Camera
        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
        camera.position.z = 2;
        // camera.lookAt(new THREE.Vector3());

        // Mesh = Geometry + Material
        const geometry = new THREE.PlaneGeometry(1, 1);
        const material = new THREE.MeshBasicMaterial({
            color: 0x0ca678,
            // wireframe: true
        });
        const mesh = new THREE.Mesh(geometry, material);
        scene.add(mesh);

        // Renderer
        const renderer = new THREE.WebGLRenderer();
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setClearColor("#e6fcf5", 1);
        document.body.appendChild(renderer.domElement);

        // Animation
        function animate() {
            requestAnimationFrame(animate);
            // mesh.rotation.y += 0.01;
            renderer.render(scene, camera);
        }
        animate();
    </script>
</body>
</html>

如果你之前没接触过 Three.js,建议先看看 Three.js 官方文档的这篇教程「创建一个场景(Creating a scene)」,或者看看古柳之前安利过的b站up主进华简洁清晰的视频——「three.js教程-从入门到入门」

如果你喜欢看书学习,刚好「Learn Three.js/Three.js开发指南」英文第四版前几个月上市,zlibrary 上已经有电子书,大家可自行下载,也可以在去「牛衣古柳」后台回复 learn three.js 获取 PDF 版本。

点线面体

目前我们在场景里放了一个宽高 1x1 的平面,当开启线框模式 wireframe: true,会发现默认的平面由4个顶点、2个三角形组成。

const geometry = new THREE.PlaneGeometry(1, 1);
const material = new THREE.MeshBasicMaterial({
    color: 0x0ca678,
    wireframe: true
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

顶点连成线、线组成三角形、三角形组成几何体,立方体、球体等亦是如此。

顶点属性数据

console.log(geometry) 打印几何体,可以看到其中 attributes 上携带的每个顶点的数据,里面包含顶点坐标 position、纹理坐标 uv、法线 normal。

这里 count 是顶点数,itemSize 是每个属性的维度数,比如 position 是3维的、uv 和 normal 都是2维的,具体说来就是 position 的 array 里是3个一组表示某个顶点的坐标数据,uv、normal 同理。配图里古柳按照每个顶点一条数据的格式进行排列,方便大家理解。

因为平面宽高均为1,默认几何体在3维坐标系的原点(0, 0, 0)处居中且面朝z轴正方向显示,所以顶点坐标依次如图所示,挺好理解的。

uv 纹理坐标

而 uv 纹理坐标很多人可能第一次接触不太了解,其实只需要记住这张图,u 从左到右增加、v 从下到上增加,范围从左下角 (0, 0) 到右上角 (1, 1) 即可,哪怕是长方形平面、球体、复杂3D模型等物体,其顶点上的纹理坐标都是这个范围。借助 uv 就能把纹理图片贴到3D物体上,这也是 uv 的一大用处,后续会演示,很简单。

别看只有几个顶点才有 uv 值,其实三角形内每个片元/像素后续都会自动插值得到数值,这背后借助了三角形重心坐标/barycentric coordinates这一良好特性,感兴趣的朋友可自行了解,这里只需知道平面 plane 内每个位置都会有 uv 值,比如中心位置是 (0.5, 0.5)。

而顶点上的属性,后续在 vertex shader 顶点着色器里就能直接获取到并使用。我们也可以往顶点上挂所需的数据,即自定义属性,然后在着色器里使用,这些后续都会介绍。

Shader 登场

讲完顶点上的属性数据,尤其是后续会用到的 uv 值,接下来该 Shader 着色器登场,看看具体能用 uv 做些什么(得到下一篇文章才来得及讲了)。

「断更19个月,携 Three.js Shader 归来!(下) - 牛衣古柳」一文,古柳就简单演示过 Three.js 里要写的 Shader 代码长什么样,这里展开介绍下细节。

首先我们不再需要使用 Three.js 内置的那些材质,而是用 ShaderMaterial 替换 MeshBasicMaterial,并且通过设置 vertexShader 顶点着色器和 fragmentShader 片元着色器,来实现自定义每个顶点、每个片元/像素如何显示。

const vertex = `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragment = `
  void main() {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  }
`;

// const material = new THREE.MeshBasicMaterial({ color: 0x0ca678 });
const material = new THREE.ShaderMaterial({
  vertexShader: vertex,
  fragmentShader: fragment
});

vertexfragment 里是用类似C语言的 GLSL 语言——即 OpenGL Shading Language——写的 shader 程序,由 GPU 分别对每个顶点、每个片元独立执行,并且每个顶点或片元都不知道其他顶点或片元的数据。这句话看似不起眼,但古柳觉得却是大家刚接触 shader 时觉得很抽象不好理解的原因之一,也是古柳刚开始入门时觉得非常重要的一个切入点,后续讲到具体例子时会再提,这里大家还一头雾水也没事。

shader 程序可以单独写在诸如 vertex.glslfragment.glsl 的文件里再导入使用;也可以和示例一样直接在 JavaScript 里用字符串格式表示。这里为了讲解方便采取后者方式。

在顶点着色器里需要设置 gl_Position 顶点位置,在片元着色器里需要设置 gl_FragColor 片元/像素颜色,两者都在没有返回值的 void main() {} 主函数里设置,并且 main 函数会被自动执行。在后续很多例子里我们都只需要设置这俩内置变量的值,只有等介绍粒子系统时才会在顶点着色器里另外设置 gl_PointSize 粒子大小。

Vertex Shader

一般的教程会先讲片元着色器,先不去改变顶点、改变三维物体形状,毕竟先把另一个放一边讲起来更方便,而设置颜色相对好讲些。但即使如此,要确保 Three.js Shader 里代码完整性,最简单的顶点着色器也要设置这一串东西,以确保三维空间的物体呈现在二维屏幕上。

const vertex = `
  void main() {
    // gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

这里虽然就一行代码,但背后原理需要一整篇文章进行介绍,此处罗列了一些文章仅供参考,由于涉及不少图形学知识,如果大家目前还不理解,也不重要,只需知道必须这样固定设置才能显示,看完下面介绍初步了解后跳到 GLSL 基础讲解与片元着色器学习颜色设置也是可以的。

虽然解释起来很为难,但古柳还是简单讲下,这里的 position 就是上文提到的几何体 attributes 里各顶点的坐标,它是三维向量类型 vec3,需要先变成 vec4 四维向量类型——即(x, y, z, w)4个分量的格式——因为有了第4个值 w=1.0 才能进行矩阵操作,实现旋转、平移、缩放,这一步通过乘以 modelMatrix 模型矩阵将原本模型以自身本地坐标定位的方式变成世界坐标里适当的位置和大小,从而实现想要的场景布局;场景有了,相机位置、视角的不同看到的画面也会不同,这一步通过乘以 viewMatrix 视图矩阵实现物体基于相机的位置,前两者可以简写成 modelViewMartix,最后再通过乘以 projectionMatrix 投影矩阵变换到剪裁空间,最终变成二维屏幕上渲染出来的效果。

之前在 b 站看犹他大学的计算机图形学课程,感觉这部分课件很直观。一开始椅子、桌子等三维物体都是基于自身中心来放置,然后通过平移、缩放、旋转等模型变换构建出合适的场景世界里的布局,然后通过设置的相机看到特定的画面,最后再渲染到画面里。

需要注意的是,这里的变量 modelViewMatrixprojectionMatrix 和属性 position 都是 ShaderMaterial 里内置的可以直接拿来用,已经帮我们声明好了,顶点上的 uv、normal 也可以在顶点着色器里直接使用;假如换成 RawShaderMaterial 就需要自己声明后才能用,当然目前古柳没怎么看到过直接用 RawShaderMaterial 的。其他内置变量和属性可以参考这篇文档。

GLSL 语言基础

在开始讲 fragment shader 里如何给片元/像素赋颜色值之前,有必要先讲下 GLSL 语言基础。

如果你看本文时也跟着敲了代码,并且“自作聪明”地省略了 GLSL 每行语句最后的分号;,或者把 float 浮点数0.0、1.0偷懒写成了整型 0、1,那么你就会发现画面里的物体无法显示、控制台有报错,因为 GLSL 里这两点是很严格的,这也是大家不论刚入门 Shader、还是后续编写 Shader 过程中一不小心就非常容易出错的地方,务必牢记在心。

不过古柳刚测试了下,这几处数字现在写成整数也不会出错了,记得以前是会报错的,而且大家看网上其他教程大多都是浮点数的写法,所以虽然可能向量类型 vec 里支持整数了,但后续教程里古柳可能还是会延续浮点数的写法,这点需要大家注意下。

const vertex = `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragment = `
  void main() {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  }
`;

GLSL 里变量数据类型有浮点数 float、整型 int、布尔型 bool,但以古柳目前接触过的代码里,浮点数的使用远远多于后两者。

float alpha = 0.5;
int num = 10;
bool flag = true;

另外还有 vec 向量类型系列,其中包含二维向量 vec2、三维向量 vec3、四维向量 vec4,可以分别看成由 (x,y)、(x,y,z)或(r,g,b)、(x,y,z,w)或(r,g,b,a) 等分量组成,并且可以像下面代码里一样直接访问或修改对应分量数值;当分量的值都一样时,可以只写一个值;向量之间或向量与浮点数之间的加减乘除四则运算,都是基于每个分量单独计算的。

另外 vec3 可以由 vec2+float 创建、vec4 可以由 vec3+float、vec2+vec2 等不同方式创建,比较灵活。

vec2 a = vec2(1.0, 0.0);
// a.x=1.0 a.y=0.0
a.x = 2.0;
a.y = 0.5;

vec2 a = vec2(1.0);
// a.x=1.0 a.y=1.0

// 向量之间或向量与浮点数之间的加减乘除四则运算是基于每个分量单独计算
vec2 a = vec2(1.0) + vec2(0.1, 0.2);
// a.x=1.1 a.y=1.2
vec2 a = vec2(1.0) * 2.0;
// a.x=2.0 a.y=2.0

vec3 b = vec3(1.0, 2.0, 0.0);
// b.x=1.0 b.y=2.0 b.z=0.0
// b.r=1.0 b.g=2.0 b.b=0.0
b.z = 3.0;

vec4 c = vec4(1.0, 1.0, 1.0, 1.0);
// c.x=c.y=c.z=c.w=1.0
// c.r=c.g=c.b=c.a=1.0
c.r = 0.9
c.g = 0.0;
c.b = 0.0;

vec3 d = vec3(vec2(0.5), 1.0);
vec4 e = vec4(vec3(1.0), 1.0);
vec4 e = vec4(vec2(0.3), vec2(0.1));

vec3(1.0) 既可以看成是 x=y=z=1.0 处的坐标点,也可以看成是从原点指向该点的向量,还可以看成是 r=g=b=1.0 也就是白色颜色,注意在 GLSL 里 rgb 范围都是 0.0-1.0,而非一般的 0-255。黑色为(0.0,0.0,0.0)、白色为(1.0,1.0,1.0)、红色为(1.0,0.0,0.0)......如果设置的颜色值小于0.0会被截取到0.0,大于1.0会被截取到1.0。

另外 GLSL 里的函数需要以返回值的类型开头,没有返回值就如同 main 主函数一样以 void 开头,函数里有参数的需要写明数据类型;参数个数、参数类型、返回值类型等不同的同名函数,可以同时存在以实现类似功能,这就是函数重载,比如这里虚构的函数,支持传入不同格式的 rgb 颜色数值以得到 hsl 颜色格式。

vec3 rgb2hsl(float r, float g, float b){
  return vec3(1.0, 1.0, 1.0);
}

vec3 rgb2hsl(vec3 color){
  return vec3(1.0, 1.0, 1.0);
}

Fragment Shader

有了前面这些 GLSL 基础,回过头看 fragment shader 里的代码,想来大家应该没什么疑问了吧。

设置 gl_FragColor 为 vec4(1.0, 0.0, 0.0, 1.0) 就是设置红色,这段代码会通过 GPU 对所有 plane 上的像素执行,于是呈现出来的就是红色的平面。

const fragment = `
  void main() {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  }
`;

下篇内容更精彩

原本想继续以 uv 为切入点,讲讲将其直接用于 fragment shader 里作为颜色,并结合 GLSL 的一些内置函数以生成重复的条纹、重复的圆圈等效果,但一些必要介绍一写起来篇幅就冗长起来了,还没来及讲的内容只能留到下一篇继续,这里先给大家看看早就生成的一些文章配图尝尝鲜(当然朋友圈和群里早就放出去了,也欢迎新朋友来围观与交流)。

另外,本文只讲了些古柳觉得目前大家需要知道的 GLSL 基础,有意不写成大而全的形式,只涉及当前够用的知识,后面等讲到其他例子时再一步步带出来更多内容。当然如文章开头所言,讲解地可能仍不清楚,如果你想了解更多 GLSL 基础内容,可以看看这篇文章,古柳觉得还不错。

照例

最后和古柳交流,也请多点赞支持!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容