手把手教你原生 JS+WebGL 實(shí)現(xiàn)3D圖片效果!
(給前端大學(xué)加星標(biāo),提升前端技能.)
作者:oj8kay
my.oschina.net/codingDog/blog/3164412
海外黨玩F***book的時(shí)候可能有接觸過(guò)這個(gè)酷炫的3d圖片效果:

只要通過(guò)客戶端的這個(gè)入口——

或者網(wǎng)頁(yè)版的這個(gè)入口——

就能生成。不知道咋玩的請(qǐng)參考官方的幫助手冊(cè)。今天就教大家手?jǐn)]出一個(gè)這樣的功能,不要擔(dān)心,所有代碼加起來(lái)不超過(guò)200行并且不使用任何第三方庫(kù)。雖然canvas2D也能做出這個(gè)效果,但是基于這種像素級(jí)操作的性能考慮,WebGL顯然是更好的方案,我前面的有些教程也用到了WebGL,核心的API我就不做過(guò)多介紹,直接詳細(xì)地注釋在最終的代碼里面了,代碼仍然使用WebGL 1.0版本。
老規(guī)矩,還是先介紹原理,推薦有想法的讀者略過(guò)教程,自己直接根據(jù)原理去擼出來(lái),因?yàn)槲疫€是秉持著話癆的特色,想到什么說(shuō)什么,教程中摻雜一些自己的干貨,對(duì)一些人來(lái)說(shuō)可能過(guò)于啰嗦。夾,哈吉咩馬修!(工地日語(yǔ)
非死不可客戶端在上傳圖片的時(shí)候你有兩種可選操作:
一種是上傳帶深度通道的圖片,即圖片的每個(gè)像素是RGB-D格式,如果你是蘋果手機(jī)可能在相機(jī)里會(huì)有人像模式或景深模式,拍出來(lái)的照片在本地是heic格式的文件,一般這種就是帶深度信息的(有興趣的可以去維基了解下這種heif編碼的圖片,可以做到很多神奇的事)。通常有TOF鏡頭的手機(jī)都能拍出這種圖片,但是不知道為啥F***book似乎只支持三星系列和自己發(fā)布的安卓機(jī)?
另一種辦法就是上傳兩張圖,一張普通的RGB像素的原圖,一張灰度圖,只要灰度圖的文件名和原圖一樣,加上_depth的后綴即可。比如666.jpg和666_depth.jpg。這也是F***book網(wǎng)頁(yè)版唯一支持的方式。這個(gè)灰度圖的門道可就多了,也是我們后面代碼實(shí)現(xiàn)的核心。開(kāi)發(fā)過(guò)游戲的一定知道深度貼圖,或者陰影貼圖/光照貼圖,其實(shí)都是類似的玩意,這種貼圖存儲(chǔ)了原圖每個(gè)像素的深度信息,貼圖的每個(gè)像素的R值就是原圖的z軸偏移,因?yàn)橐话闵疃荣N圖的R、G、B通道的值相同,所以表現(xiàn)出來(lái)的就是一張灰度圖。
如何獲取深度貼圖呢?如果你有heic格式的帶深度信息的照片,可以用PS抽取出z通道的信息(windows上的PS不支持),如果你啥都沒(méi)有,我會(huì)在下個(gè)教程嘗試“教”你一下如何在PS中繪制出深度貼圖,或者使用谷歌提供的一個(gè)人工智能程序來(lái)生成,我也會(huì)寫入下個(gè)教程,親測(cè)匹配程度還是挺高的~
具體是怎么產(chǎn)生3D效果的呢?深度貼圖中,顏色越淺(值越小)表示深度約低,通過(guò)深度貼圖的深度值來(lái)對(duì)原圖的采樣位置進(jìn)行偏移,比如當(dāng)你把貼圖往左偏移,然后使用偏移的距離乘上原圖的某個(gè)坐標(biāo)在貼圖上的深度值得到的結(jié)果來(lái)對(duì)原圖進(jìn)行采樣,就會(huì)得到不同的點(diǎn)在不同的深度偏移的大小不同的情況,距離越近的偏移越小,距離越遠(yuǎn)的偏移越大,是不是很符合我們生活中的常識(shí)?事實(shí)上,拋棄主觀感知,從底層角度考慮,最終展現(xiàn)出來(lái)的效果其實(shí)就是一部分的像素點(diǎn)被壓縮了,一部分的像素點(diǎn)被拉伸了。不知道大家有沒(méi)有用過(guò)live2D或者Spine、龍骨等工具做出來(lái)的動(dòng)畫,就是這種:

剛剛所說(shuō)的底層變化是不是和這種網(wǎng)格動(dòng)畫很像,其實(shí)都是對(duì)圖片的變形來(lái)達(dá)到3D效果,就單張圖的變化而言,他們的唯一區(qū)別就是蒙皮動(dòng)畫是手動(dòng)key幀(或者是骨骼綁定——這個(gè)以后有機(jī)會(huì)談?wù)劊?D圖片是通過(guò)深度貼圖自動(dòng)生成。
廢話終于說(shuō)完,下面開(kāi)始編碼,先設(shè)置一下基礎(chǔ)樣式:
* {
margin: 0;
padding: 0;
}
body {
width: 100vw;
height: 100vh;
position: relative;
background-color: #000;
}
canvas {
position: absolute;
left: 50%;
top: 50%;
transform: translate3d(-50%, -50%, 0);
}然后引入glMatrix函數(shù)庫(kù)用于操作矩陣(雖然,之前說(shuō)好的不依賴第三方庫(kù),不過(guò)坐標(biāo)換算確實(shí)挺煩~免得程序太長(zhǎng)還有寫一堆注釋~其實(shí)換算也不難,看過(guò)上一篇教程的應(yīng)該自己實(shí)現(xiàn)問(wèn)題也不大~~原諒我標(biāo)題黨 ???)
<script src="./gl-matrix-min.js">script>我已經(jīng)下載好了,想要消息了解這個(gè)函數(shù)庫(kù)的可以去glMatrix官網(wǎng),這個(gè)庫(kù)非常小,未壓縮前也就100多K。
頂點(diǎn)著色器(shader_vertex.vert)的代碼:
attribute vec2 a_pos;
attribute vec2 a_uv;
uniform mat4 u_proj;
varying vec2 v_uv;
void main() {
v_uv = a_uv; // 將紋理坐標(biāo)傳遞到片元著色器
gl_Position = u_proj * vec4(a_pos, 0.0, 1.0);
}片元著色器的代碼:
precision highp float;
uniform sampler2D u_sampler;
varying vec2 v_uv;
void main() {
gl_FragColor = texture2D(u_sampler, v_uv);
}直接貼上繪制靜態(tài)圖的代碼:
init()
async function init () {
const { mat4 } = glMatrix
const PAGE_WIDTH = document.body.clientWidth
const PAGE_HEIGHT = document.body.clientHeight
// 設(shè)置畫布寬高
const CANVAS_WIDTH = 900
const CANVAS_HEIGHT = 900
const canvas = document.createElement('canvas')
canvas.width = CANVAS_WIDTH
canvas.height = CANVAS_HEIGHT
document.body.appendChild(canvas)
const gl = canvas.getContext('webgl')
gl.viewport(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT)
// 加載圖片(原圖和深度圖尺寸一致)
const image = new Image()
image.src = './sennpai.jpg'
await new Promise(resolve => image.complete ? resolve() : (image.onload = e => resolve()))
// 若圖片寬高超出限制,以類似 background-size:contain 的方式將圖片縮放居中
let ratio = 1
if (image.height > CANVAS_HEIGHT) {
ratio = CANVAS_HEIGHT / image.height
}
if (image.width * ratio > CANVAS_WIDTH) {
ratio = CANVAS_WIDTH / image.width
}
const imgWidth = image.width * ratio
const imgHeight = image.height * ratio
// 獲取頂點(diǎn)著色器源碼
let res = await fetch('./shader_vertex.vert', { method: 'get', })
let shaderSrc = await res.text()
// 創(chuàng)建頂點(diǎn)著色器
const vs = gl.createShader(gl.VERTEX_SHADER)
gl.shaderSource(vs, shaderSrc)
gl.compileShader(vs)
// 獲取著色器信息
if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS)) {
// 打印編譯失敗日志
console.error(`Error compile shader:\n${shaderSrc}\n=====error log======\n${gl.getShaderInfoLog(vs)}`)
gl.deleteShader(vs)
return null
}
// 獲取片元著色器源碼
res = await fetch('./shader_fragment.frag', { method: 'get', })
shaderSrc = await res.text()
// 創(chuàng)建片元著色器
const fs = gl.createShader(gl.FRAGMENT_SHADER)
gl.shaderSource(fs, shaderSrc)
gl.compileShader(fs)
if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) {
console.error(`Error compile shader:\n${shaderSrc}\n=====error log======\n${gl.getShaderInfoLog(fs)}`)
gl.deleteShader(fs)
return null
}
// 創(chuàng)建program
const prg = gl.createProgram()
gl.attachShader(prg, vs)
gl.attachShader(prg, fs)
gl.linkProgram(prg)
gl.useProgram(prg)
// 設(shè)置投影矩陣
const projMat4 = mat4.create()
/**
* ortho(out, left, right, bottom, top, near, far)
*/
mat4.ortho(projMat4, -CANVAS_WIDTH / 2, CANVAS_WIDTH / 2, -CANVAS_HEIGHT / 2, CANVAS_HEIGHT / 2, 0, 500)
// 獲取投影矩陣的地址
const uProj = gl.getUniformLocation(prg, 'u_proj')
// 將投影矩陣傳入
gl.uniformMatrix4fv(uProj, false, projMat4)
// 使用頂點(diǎn)數(shù)組創(chuàng)建vbo
const vertexList = new Float32Array([
// x y u v
-imgWidth / 2, imgHeight / 2, 0, 0,
-imgWidth / 2, -imgHeight / 2, 0, 1,
imgWidth / 2, imgHeight / 2, 1, 0,
imgWidth / 2, -imgHeight / 2, 1, 1,
])
// 獲取數(shù)組每個(gè)元素的大小(用于計(jì)算步長(zhǎng))
const PER_ELEMENT_SIZE = vertexList.BYTES_PER_ELEMENT
const buffer = gl.createBuffer()
/**
* 綁定緩沖區(qū)
* @param target 數(shù)據(jù)類型
* @param buffer 緩沖區(qū)對(duì)象
*/
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
/**
* 向緩沖區(qū)寫入數(shù)據(jù)
* @param target 數(shù)據(jù)類型
* @param data 數(shù)據(jù)(這里是類型化數(shù)組)
* @param usage 繪制方式(用于幫助webgl優(yōu)化)
*/
gl.bufferData(gl.ARRAY_BUFFER, vertexList, gl.STATIC_DRAW)
// 獲取頂點(diǎn)坐標(biāo)變量在著色器中的地址
const aPos = gl.getAttribLocation(prg, 'a_pos')
/**
* 將緩沖區(qū)對(duì)象分配給attribute變量
* @param location:變量的存儲(chǔ)地址
* @param size:每個(gè)頂點(diǎn)分量個(gè)數(shù),若個(gè)數(shù)比變量的數(shù)量少,則按照gl.vertexAttrib[1234]f的規(guī)則來(lái)補(bǔ)全
* @param type:指定數(shù)據(jù)類型
* @param normalized:是否需要?dú)w一化
* @param stride:相鄰兩個(gè)頂點(diǎn)之間的字節(jié)數(shù)(只有一種數(shù)據(jù)則為0)
* @param offset:數(shù)據(jù)的偏移量(單位字節(jié),只有一種數(shù)據(jù)則為0)
*/
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, PER_ELEMENT_SIZE * 4, 0)
// 允許aPos訪問(wèn)VBO
gl.enableVertexAttribArray(aPos)
// 獲取紋理坐標(biāo)變量在著色器中的地址
const aUV = gl.getAttribLocation(prg, 'a_uv')
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, PER_ELEMENT_SIZE * 4, PER_ELEMENT_SIZE * 2)
// 允許aUV訪問(wèn)VBO
gl.enableVertexAttribArray(aUV)
// 使用完后解綁VBO
gl.bindBuffer(gl.ARRAY_BUFFER, null)
// 創(chuàng)建紋理對(duì)象
const texture = gl.createTexture()
// 激活0號(hào)紋理單元
gl.activeTexture(gl.TEXTURE0)
// 綁定并開(kāi)啟0號(hào)紋理單元
gl.bindTexture(gl.TEXTURE_2D, texture)
// 指定縮小算法
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
// 指定放大算法
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
// 指定水平方向填充算法
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
// 指定垂直方向填充算法
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
/**
* 通過(guò)0號(hào)紋理單元將圖片分配給紋理對(duì)象
* target 指定為2D紋理
* level 金字塔紋理
* internalFormat 圖片內(nèi)部格式
* format 紋理格式(必須與internalFormat相同)
* type 紋理數(shù)據(jù)類型
* image 圖片
*/
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
// 獲取紋理對(duì)象在著色器中的地址(使用uniform,因?yàn)槊總€(gè)頂點(diǎn)操作的都是同一個(gè)紋理)
const uSampler = gl.getUniformLocation(prg, 'u_sampler')
// 指定從0號(hào)紋理單元獲取紋理
gl.uniform1i(uSampler, 0)
// 渲染循環(huán)
function loop () {
gl.clearColor(0.0, 0.0, 0.0, 1.0)
gl.clear(gl.COLOR_BUFFER_BIT) // 清空顏色緩沖區(qū)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
requestAnimationFrame(loop)
}
loop()
}都是一些常規(guī)的操作,具體api的作用寫入注釋里就不做多解釋了。
接下來(lái)把我們的深度貼圖傳入著色器,主要是這幾個(gè)步驟:
①獲取加載完成的圖片對(duì)象:
const depthImage = new Image()
depthImage.src = './sennpai_depth.jpg'
await new Promise(resolve => depthImage.complete ? resolve() : (depthImage.onload = e => resolve()))因?yàn)槿绻麨g覽器如果已經(jīng)緩存了圖片不一定會(huì)觸發(fā)onload事件。所以我們先通過(guò)complete屬性來(lái)判斷圖片的加載狀態(tài)是否為已完成。
②修改片元著色器代碼,通過(guò)深度貼圖對(duì)原圖來(lái)進(jìn)行采樣:
precision highp float;
uniform sampler2D u_sampler;
uniform sampler2D u_sampler_depth;// 深度貼圖采樣器
uniform vec2 u_offset;// 深度貼圖的偏移
varying vec2 v_uv;
void main() {
float depth = texture2D(u_sampler_depth, v_uv).r;// 獲取深度信息
gl_FragColor = texture2D(u_sampler, v_uv + depth * u_offset);
}獲取貼圖的R通道的值作為深度值
③通過(guò)另一個(gè)紋理單元(如1號(hào)紋理單元)將貼圖傳入片元著色器:
// 同理,創(chuàng)建深度貼圖的紋理
const depthTexture = gl.createTexture()
// 綁定并開(kāi)啟1號(hào)紋理單元
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, depthTexture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, depthImage)
const uSamplerDepth = gl.getUniformLocation(prg, 'u_sampler_depth')
// 指定從1號(hào)紋理單元獲取紋理
gl.uniform1i(uSamplerDepth, 1)這時(shí)候看到最后效果沒(méi)有任何變化,因?yàn)槲覀冞€沒(méi)有對(duì)貼圖進(jìn)行偏移,u_offset默認(rèn)值是vec(0.0,0.0)。
接下來(lái)可以給頁(yè)面綁定mousemove事件,我這里限定了u,v最大的偏移量為0.05,把渲染循環(huán)函數(shù)放到事件回調(diào)中:
const uOffset = gl.getUniformLocation(prg, 'u_offset')
const scale = 0.1
document.body.onmousemove = e => {
gl.uniform2f(uOffset, scale * (e.pageX / PAGE_WIDTH - 0.5), scale * (e.pageY / PAGE_HEIGHT - 0.5))
loop()
}
// 繪制循環(huán)
function loop () {
gl.clearColor(0.0, 0.0, 0.0, 1.0)
gl.clear(gl.COLOR_BUFFER_BIT) // 清空顏色緩沖區(qū)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
// requestAnimationFrame(loop)
}ok,大功告成,預(yù)覽一下效果圖——
:

分享前端好文,點(diǎn)亮?在看?
