大家好,我是西瓜,你现在看到的是 2021 iThome 铁人赛『如何在网页中绘制 3D 场景?从 WebGL 的基础开始说起』系列文章的第 23 篇文章。本系列文章从 WebGL 基本运作机制以及使用的原理开始介绍,最後建构出绘制 3D、光影效果之网页。本章节讲述的是如何透过 framebuffer 使 WebGL 预先计算资料到 texture,并透过这些预计算的资料制作镜面、阴影效果,如果在阅读本文时觉得有什麽未知的东西被当成已知的,可能可以在前面的文章中找到相关的内容
取得了 framebuffer 这个工具,把球体画在在一幅画中,已经完成镜面效果所需要的基石,本篇来把镜面效果实做出来
上篇建立 framebuffer 时直接使用 WebGL API 来建立 framebuffer,其实 TWGL 已经有时实做好一定程度的包装,我们可以呼叫 twgl.createFramebufferInfo()
,它会建立好 framebuffer, textures 并且为他们建立关联:
framebuffers.mirror = twgl.createFramebufferInfo(
gl,
null, // attachments
2048, // width
2048, // height
);
attachments
让开发者可以指定要写入的 texture 的设定,例如说 gl.COLOR_ATTACHMENT0
所对应的颜色部份要写入的 texture 的设定,笔者传 null 让 twgl 使用预设值建立一个颜色 texture 以及一个深度资讯 texture,因为接下来要实做的功能为镜面,把此 framebuffer 命名为 framebuffers.mirror
那麽要怎麽取得自动建立的 texture 呢?尝试用 Console 查看建立的物件 framebufferInfo
看起来像是这样:
看起来就放在 attachments
下呢,那麽把 texture 指定到 textures
物件中以便之後取用:
textures.mirror = framebuffers.mirror.attachments[0];
值得注意的是,framebufferInfo
同时包含了长宽资讯,如果使用 twgl.bindFramebufferInfo()
来做 framebuffer 的切换,它同时会帮我们呼叫 gl.viewport()
调整渲染区域,因此在绘制阶段也使用 twgl 所提供的工具:
function render(app) {
// ...
- gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffers.fb.framebuffer);
- gl.viewport(0, 0, framebuffers.fb.width, framebuffers.fb.height);
- gl.clear(gl.COLOR_BUFFER_BIT);
+ twgl.bindFramebufferInfo(gl, framebuffers.mirror);
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
renderBall(app, viewMatrix);
// ...
}
可以发现在 gl.clear()
时除了清除 gl.COLOR_BUFFER_BIT
,也要清除gl.DEPTH_BUFFER_BIT
,这是因为 twgl.createFramebufferInfo()
所建立的组合预设包含了一张深度资讯,这个资讯也得清除以避免第二次渲染到 framebuffer 时产生问题
目前在 framebuffer 中绘制的球体就是正常状态下看到的球体,那麽要怎麽绘制『镜像』的样子呢?想像一个观察着看着镜面中的一颗球:
橘色箭头为实际光的路线,把光线打直可以获得一个镜面中的观察者看着真实世界(灰色箭头与眼睛),因此绘制镜像中的世界时,把相机移动到镜面中拍一次,我们就获得了镜面世界的成像,准备好在绘制场景时使用
笔者为此章节实做的相机控制方式使用了不同於 matrix4.lookAt()
的 cameraMatrix
产生方式:
const cameraMatrix = matrix4.multiply(
matrix4.translate(...state.cameraViewing),
matrix4.yRotate(state.cameraRotationXY[1]),
matrix4.xRotate(state.cameraRotationXY[0]),
matrix4.translate(0, 0, state.cameraDistance),
);
用白话文来说,目前的相机一开始在 [0, 0, 0]
看着 -z 方向,先往 +z 方向移动 state.cameraDistance
、转动 x 轴 state.cameraRotationXY[0]
、转动 y 轴 state.cameraRotationXY[1]
,这时相机会在半径为 state.cameraDistance
的球体表面上看着原点,最後 state.cameraViewing
的平移是指移动相机所看的目标,如果使用 y = 0
形成的平面作为镜面,只要让转动 x 轴时反向,就变成对应在镜面中的相机,并且进而算出镜面使用的 viewMatrix
:
const mirrorCameraMatrix = matrix4.multiply(
matrix4.translate(...state.cameraViewing),
matrix4.yRotate(state.cameraRotationXY[1]),
matrix4.xRotate(-state.cameraRotationXY[0]),
matrix4.translate(0, 0, state.cameraDistance),
);
const mirrorViewMatrix = matrix4.multiply(
matrix4.perspective(state.fieldOfView, gl.canvas.width / gl.canvas.height, 0.1, 2000),
matrix4.inverse(mirrorCameraMatrix),
);
接着让地板为 y = 0
形成的平面,与球体一同向 +y 方向移动一单位:
function renderBall(app, viewMatrix) {
// ...
const worldMatrix = matrix4.multiply(
- matrix4.translate(0, 0, 0),
matrix4.scale(1, 1, 1),
);
// ...
}
function renderGround(app, viewMatrix) {
// ...
- const worldMatrix = matrix4.multiply(
- matrix4.translate(0, -1, 0),
- matrix4.scale(10, 1, 10),
- );
+ const worldMatrix = matrix4.scale(10, 1, 10);
// ...
}
最後在绘制镜像中的世界时使用 mirrorViewMatrix
:
function render(app) {
// ...
twgl.bindFramebufferInfo(gl, framebuffers.mirror);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
- renderBall(app, viewMatrix);
+ renderBall(app, mirrorViewMatrix);
// ...
}
尽管绘制出镜面世界中的样子,拍了一张镜面世界的照片,但是要怎麽在正式『画』的时候找到镜面世界的照片对应的位置呢?请看下面这张图:
物件上的一个点 A,是经过物件自身 worldMatrix
transform 的位置,再经过 mirrorViewMatrix
transform 到镜面世界的照片上(点 B);正式『画』镜面物件时,我们知道的是 C 点的位置(worldPosition
),而这个点座落在 A 与 B 点之间,因此拿着 C 点做 mirrorViewMatrix
transform 便可以获得对应的 B 点,这时 B 点是 clip space 中的位置,只要再将此位置向量加一除以二就能得到 texture 上的位置罗
也就是说,在正式『画』的时候也会需要 mirrorViewMatrix
,uniform 命名为 u_mirrorMatrix
,并且在 vertex shader 中计算出 B 点,透过 varying v_mirrorTexcoord
传送给 fragment shader:
// ...
+uniform mat4 u_mirrorMatrix;
+varying vec4 v_mirrorTexcoord;
void main() {
// ...
- vec3 worldPosition = (u_worldMatrix * a_position).xyz;
- v_surfaceToViewer = u_worldViewerPosition - worldPosition;
+ vec4 worldPosition = u_worldMatrix * a_position;
+ v_surfaceToViewer = u_worldViewerPosition - worldPosition.xyz;
+
+ v_mirrorTexcoord = u_mirrorMatrix * worldPosition;
}
到 fragment shader,笔者打算让镜面世界的照片放在 u_diffuseMap
,不过镜面物体取用 texture 的方式将会与其他物件不同,因此加入一个 uniform u_useMirrorTexcoord
来控制是否要使用 v_mirrorTexcoord
// ...
+uniform bool u_useMirrorTexcoord;
+varying vec4 v_mirrorTexcoord;
void main() {
+ vec2 texcoord = u_useMirrorTexcoord ?
+ (v_mirrorTexcoord.xy / v_mirrorTexcoord.w) * 0.5 + 0.5 :
+ v_texcoord;
- vec3 diffuse = u_diffuse + texture2D(u_diffuseMap, v_texcoord).rgb;
+ vec3 diffuse = u_diffuse + texture2D(u_diffuseMap, texcoord).rgb;
vec3 ambient = u_ambient * diffuse;
- vec3 normal = texture2D(u_normalMap, v_texcoord).xyz * 2.0 - 1.0;
+ vec3 normal = texture2D(u_normalMap, texcoord).xyz * 2.0 - 1.0;
// ...
}
可以注意到 u_useMirrorTexcoord
为 true 时,有个 (v_mirrorTexcoord.xy / v_mirrorTexcoord.w)
,为什麽要除以 .w
呢?还记得 Day 12 时,顶点位置在进入 clip space 之前,会把 gl_Position.x
, gl_Position.y
, gl_Position.z
都除以 gl_Position.w
,而 varying v_mirrorTexcoord
当然就没有这样的行为了,我们得自己实做,然後 * 0.5 + 0.5
就是把 clip space 位置(-1 ~ +1)转换成 texture 上的 texcoord (0 ~ +1)
完成 shader 的修改,剩下的就是把需要喂进去的 uniform 喂进去,并且在正式『
『画』的时候也画出球体:
function render(app) {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.canvas.width = gl.canvas.clientWidth;
gl.canvas.height = gl.canvas.clientHeight;
gl.viewport(0, 0, canvas.width, canvas.height);
+ renderBall(app, viewMatrix);
- renderGround(app, viewMatrix);
+ renderGround(app, viewMatrix, mirrorViewMatrix);
}
-function renderGround(app, viewMatrix) {
+function renderGround(app, viewMatrix, mirrorViewMatrix) {
// ...
twgl.setUniforms(programInfo, {
// ...
- u_diffuseMap: textures.fb,
+ u_diffuseMap: textures.mirror,
// ...
+ u_useMirrorTexcoord: true,
+ u_mirrorMatrix: mirrorViewMatrix,
});
twgl.drawBufferInfo(gl, objects.ground.bufferInfo);
+
+ twgl.setUniforms(programInfo, {
+ u_useMirrorTexcoord: false,
+ });
}
在最後还有特地把 u_useMirrorTexcoord
关闭,因为只有地板物件会需要这个特殊的模式,而 uniform 是跟着 program 的,画完此物件立刻关闭可以避免影响到其他物件的渲染
镜面效果就完成了:
本篇的完整程序码可以在这边找到:
<<: [Day22] Tableau 轻松学 - TabPy 介绍
>>: 初学者跪着学JavaScript Day8 : 资料型别:BigInt
运用到的观念 border搭配伪元素制作出三角形区块 绝对定位&相对定位 用:hover ...
前几天学到了 PostCSS、Babel 这些後处理器,来协助在打包时改写原始码来支援各种浏览器,今...
使用 fd-find 代替 find,效率更好 下载方式 : sudo apt-get instal...
昨天 Day 12 介绍了 Sinopac PHP SDK 关於建立订单的部分,接着今天要介绍的是查...
前言 也许你会觉得,这个标题下得很神经。没错!因为今天要正式进入新的主题-神经机器翻译。我们今天将会...