大家好,我是西瓜,你现在看到的是 2021 iThome 铁人赛『如何在网页中绘制 3D 场景?从 WebGL 的基础开始说起』系列文章的第 17 篇文章。本系列文章从 WebGL 基本运作机制以及使用的原理开始介绍,最後建构出绘制 3D、光影效果之网页。如果在阅读本文时觉得有什麽未知的东西被当成已知的,可能可以在前面的文章中找到相关的内容
在这个章节中将会加入『光』的元素,使得物体在有光照射的时候才会有颜色,并利用上个章节提到的 twgl 让程序码可以写的比较愉快,最後加入反射光的计算,使得渲染的成像更真实一些
04-lighting.html
/ 04-lighting.js
这个章节使用新的一组 .html
/ .js
作为开始,完整程序码可以在这边找到:github.com/pastleo/webgl-ironman/commit/2d72d48,这次的起始点跑起来就有一个木质地板跟一颗球:
虽然是 CC0,不过笔者还是标注一下好了,这个场景中使用到的 texture 是在 opengameart.org 找到的:Commission - Medieval, 2048² wooden texture
需要复习『在 WebGL 里头使用图片 (texture) 进行绘制』的话,请参考 Day 6 的内容,在这个起始点也拿 Day 14 实做的相机控制过来,完整的 live 版本在此:
https://static.pastleo.me/webgl-ironman/commits/2d72d48e66e41a968c65b4870c1cfb8391157710/04-lighting.html
目前木质地板与球只是按照原本 texture 上的颜色进行绘制,本篇的目标是加入一个从无限远的地方照射过来的白色平行光 (directional light),并且在物体表面计算『散射 (diffuse)』之後从任意角度观察到的颜色,在维基百科上的这张图表示了散射光的方向:
因为是白光,所以散射之後的颜色其实就是原本的颜色经过一个明暗度的处理,而明暗度要怎麽计算呢?笔者画了下方的意示图尝试解释,首先,如果是在光照不到的区域,像是红色面,与光平行或是背对着光,那麽就会是全黑;被照射到的区域,如绿色与蓝色面,因为一个单位的光通量在与垂直的面上可以形成较小的区域(在绿色面上的橘色线段较蓝色面短),一个单位的面积获得的光通量就比较高,因此绿色面比蓝色面来的更亮
总和以上,入射角越垂直面接近法向量,明暗度越高,不过在 fragment shader 内,把向量算回角度再做比较会太伤本,我们可以取光方向反向的单位向量,再与法向量(也必须是单位向量)做内积,这样一来会得到 -1 ~ +1 之间的值表示明暗度;幸好 twgl.primitives
产生的资料不只有 position, texcoord,还有 normal,也就是法向量,场景中的球以及地板都是使用 TWGL 生成的,这边就先来把 normal 传入 vertex shader 内:
const vertexShaderSource = `
attribute vec4 a_position;
attribute vec2 a_texcoord;
+attribute vec4 a_normal;
// ...
void main() {
// ...
}
`;
const attributes = {
// ...
+ normal: gl.getAttribLocation(program, 'a_normal'),
};
async function setup() {
// ...
{ // both ball and ground
// ...
// a_normal
buffers.normal = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.normal);
gl.enableVertexAttribArray(attributes.normal);
gl.vertexAttribPointer(
attributes.normal,
attribs.normal.numComponents, // size
gl.FLOAT, // type
false, // normalize
0, // stride
0, // offset
);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(attribs.normal),
gl.STATIC_DRAW,
);
// ...
}
// ...
}
计算明暗度会在面投影到萤幕的每个 pixel 上进行,也就是 fragment shader,vertex shader 主要的工作是把 a_normal
pass 到 fragment shader,但是有一个问题:物体会旋转,我们让顶点的位置透过 u_matrix
做 transform,假设有一个物体转了 90 度,那麽法向量也应该一起转 90 度才对
但是我们不能直接让 a_normal
与 u_matrix
相乘得到旋转後的结果,不仅因为 u_matrix
可能包含了平移、缩放资讯,还有投影到萤幕上的 transform,因此要多传送一个矩阵,这个矩阵只包含了 worldMatrix
(物件本身的 transform 矩阵)的旋转。至於从 worldMatrix
中抽取只包含旋转的矩阵,在下面这两个网页中有一些数学方法导出接下来的公式:
笔者尝试理解并统整成这份笔记,结论是:把 worldMatrix
取反矩阵,再取转置矩阵,就可以得到 transform 法向量用的矩阵 -- 也就是只包含旋转的 worldMatrix
在 lib/matrix.js
中已经有 matrix4.inverse()
,要补的是 matrix4.transpose()
,根据其定义,实做并不难:
export const matrix4 = {
// ...
transpose: m => {
return [
m[0], m[4], m[8], m[12],
m[1], m[5], m[9], m[13],
m[2], m[6], m[10], m[14],
m[3], m[7], m[11], m[15],
];
},
// ...
}
假设待会实做在 vertex shader 内转换 normal 的矩阵叫做 u_normalMatrix
,在 setup()
中先取得 uniform
位置:
async function setup() {
// ...
const uniforms = {
// ...
normalMatrix: gl.getUniformLocation(program, 'u_normalMatrix'),
// ...
};
// ...
}
在 render()
这边计算物件的 worldMatrix
後,依照上面讲的公式实做计算 u_normalMatrix
:
function render(app) {
// ...
{ // both ball and ground
// const worldMatrix = matrix4.multiply( ... )
gl.uniformMatrix4fv(
uniforms.normalMatrix,
false,
matrix4.transpose(matrix4.inverse(worldMatrix)),
);
}
// ...
}
在 vertex shader 内就可以直接相乘,并透过 varying v_normal
传送到 fragment shader:
attribute vec4 a_position;
attribute vec2 a_texcoord;
+attribute vec4 a_normal;
uniform mat4 u_matrix;
+uniform mat4 u_normalMatrix;
varying vec2 v_texcoord;
+varying vec3 v_normal;
void main() {
gl_Position = u_matrix * a_position;
v_texcoord = vec2(a_texcoord.x, 1.0 - a_texcoord.y);
+ v_normal = (u_normalMatrix * a_normal).xyz;
}
为了方便调整光线方向观察不同方向的成像,笔者加入 uniform u_lightDir
,并且一开始让他直直向下(-y 方向) 照射:
async function setup() {
// ...
const uniforms = {
// ...
lightDir: gl.getUniformLocation(program, 'u_lightDir'),
// ...
};
// ...
return {
// ...
state: {
// ...
lightDir: [0, -1, 0],
},
};
}
因为整个场景的光线方向都是固定的,因此在 render()
物件以外的范围设定 u_lightDir
:
function render(app) {
// ...
gl.uniform3f(uniforms.lightDir, ...state.lightDir);
// ...
}
最後终於可以来写 fragment shader 实做计算明暗度计算:
precision highp float;
uniform vec3 u_color;
uniform sampler2D u_texture;
uniform vec3 u_lightDir;
varying vec2 v_texcoord;
varying vec3 v_normal;
void main() {
vec3 color = u_color + texture2D(u_texture, v_texcoord).rgb;
vec3 normal = normalize(v_normal);
vec3 surfaceToLightDir = normalize(-u_lightDir);
float colorLight = clamp(dot(surfaceToLightDir, normal), 0.0, 1.0);
gl_FragColor = vec4(color * colorLight, 1);
}
main()
的第 2 行使用 glsl 内建的 normalize function 计算 v_normal
的单位向量,因为从 vertex shader 过来的 varying 经过『补间』处理可能导致不是单位向量,第 13 行计算『表面到光源』的方向,同样使之为单位向量
main()
的第 4 行大概就是本篇最关键的一行,如同上方讲的使用 glsl 的内积 function 计算明暗度:dot(surfaceToLightDir, normal)
,不过为了避免数值跑到负的,再套上 glsl 的 clamp 把范围限制在 0~1 之间,最後乘上原本的颜色 color
,把明暗度套用上去,存档重整後:
笔者稍微把视角往右上角调整了一下,可以看到球体的因为向着正下方的光线,只有上方比较亮,而地板因为原本就是向上的,所以就没有变化
到了这边其实应该把 color
改名成 diffuse
,因为一个物体其实可以分成不同种类的光对其表面产生的颜色,今天实做的是散射光,之後还会有反射光、自发光等;同时笔者也加上光线方向的使用者控制,完整程序码可以在这边找到:
光源方向调整起来像是这样:
<<: Day.9 备份还原 - 还原资料 (MYSQL binlog )-下
>>: 加上random与time模组,限制次数与时间的管理(3)
前言 当熟悉了历史资料,发现有更厉害的K线,这时候就要善用工具,好好的料理资料一番,这次我们来用K线...
这个问题来自於我的专案执行的经验,也让大家一起想想看。 在收到专案後,第一个动作就是去寻找这个专案的...
PHP的资料型态 Object 对象 要创建一个新的对象 object,使用 new 语句实例化一个...
内容发布到网路上,由於都是开放的,不管是你写的文章、拍摄的相片或是影片,有一定的机率会被转贴。有些人...
前言 昨天我们完成了point简单的read 跟route model controll等 今天我们...