缘由
最近在做官网的设计,产品在浏览了一种云服务产品的官网之后,满怀激动地说我要一个高级的玫瑰金的描边效果。我当时的心情犹如一只受惊的小小鸟,心想,手机壳要颜色的那种吗?话不多说砸门先看效果。
效果展示
主要技术栈
WebGL
关于WebGL 这一块同学们可以选择《WebGL 编程指南》一书进行详细的阅读,把WebGL 原生的API理解透了,对上手使用框架的话会更加有感觉。你会知道大概THREE.js的设计思想是怎么样的,从API的角度对标WebGL的原生API的角度,加了什么,保留了什么,这些都会有个大概的印象。
WebGL、 Three.js 、 GLSL Shading Language(OPENGL 着色器语言)、MVP矩阵 的数学知识 、Blender 建模简单操作
THREE.js
Threejs 官网上也有很多介绍的例子,具体可以参考threejsfundamentals、郭隆邦的技术博客。THREE.js可以帮我们省去许多webgl的API调用的繁复工作,另外抽象了相机,场景,数学矩阵,向量这种高级对象,方便我们调用。
GLSL Shading Language
着色器作为实现的重要一环,具有举足轻重的作用,本人才疏学浅,这里推荐凹凸实验室的文章和GLSL语言基础,当然,最最最重要的启蒙是thebookofshaders,里面有许多关于shader有趣玩法,另外ShaderToy社区经常有大牛分享一些炫酷特效的实现。Ray Marching ,SDF 有向距离场,不需要模型文件即可以建模。使用纯数学方式。等等……
MVP矩阵 的数学知识
对于我们虚构的世界空间,需要用一种我们定义的转换来实现3D 跟 2D 视角的相互转换,这里首先向量矩阵基础知识,然后上手投影,视图和模型矩阵更佳。
Blender 建模简单操作
使用Blender作为我的建模工具,主要还是免费~~ 不过之前也用过Maya,3DSmax,C4D,后面才发现其实Blender 也够用了,而且建模的方式跟手感经常有种令我感到惊喜。不过每一种建模工具都有他的特点,大家上手适合自己的就好。
第一步 建模
THREE.js 的单位
three 的单位目前使用的是SI Unit 就是国际标准单位,想更深入了解的话自行wiki,这边用到的就是 1单位的threejs 量 = 建模的1m(米)
模型坐标位置
如图所示,把模型按照这个坐标系进行摆放即可。
模型坐标缩放单位
这个缩放标准视你在THreejs中定义的透视相机的参数 为准,这边的话我一个模型的边界大概是
最高处模型的坐标大概是下图
模型导出
这边我选择 GLTF 格式进行导出,导出的时候注意,记得把灯光跟摄像机、动画这些去掉、我们只保留模型部分。
第二步 编写代码
加载资源文件
由于我是使用了Vue-cli 的Webpack环境来进行工程化开发,用到的相关Loader为raw-loader,url-loader
在Vue.config.js添加如下代码
config.module
.rule('gltf')
.test(/\.glb$/)
.use('url-loader')
.loader('url-loader')
.end()
config.module
.rule('shader')
.test(/\.(glsl)$/)
.use('raw-loader')
.loader('raw-loader')
.end()
编写加载函数
这边我们结合Three 的GltfLoader 对象进行了一个简单的Promise 封装
import * as THREE from 'three/build/three.module'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
const loader = new GLTFLoader()
methods:{
loadModel(url) {
return new Promise((resolve, reject) => {
loader.load(
url,
function(gltf) {
console.log(gltf)
resolve(gltf)
},
undefined,
function(error) {
reject(error)
}
)
})
},
}
资源调用我们使用require拿到gltf的构建时的资源地址或者时Base64,这个要看url-loader对于文件大小的处理
async mounted() {
this.initMaterial()
this.initRender()
this.initScene()
this.curScene = this.scene
// 加载模型文件
const res = await this.loadModel(this.src)
this.modelObject = res
this.initLight()
// this.control = new OrbitControls(this.camera, this.render.domElement)
this.addModelToScene() && this.draw()
}
初始化场景
initScene() {
this.scene = new THREE.Scene()
}
初始化透视摄像机跟渲染上下文
initRender() {
const width = this.$refs.render.clientWidth
const height = this.$refs.render.clientHeight * 0.8
this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000)
this.camera.position.set(5, 5, 5)
this.camera.lookAt(0, 0, 0)
const renderer = new THREE.WebGLRenderer({
canvas: this.$refs.render,
setPixelRatio: 2,
antialias: true,
alpha: true,
precision: 'highp'
})
// 背面剔除
renderer.setFaceCulling(THREE.CullFaceBack, THREE.FrontFaceDirectionCW)
renderer.setSize(width, height)
this.render = renderer
}
添加灯光
initLight() {
const spotLight = new THREE.SpotLight(0x111111, 4)
spotLight.position.set(8, 8, 8)
spotLight.castShadow = true
spotLight.shadow.mapSize.width = 1
spotLight.shadow.mapSize.height = 1
spotLight.shadow.camera.near = 10
spotLight.shadow.camera.far = 40
spotLight.shadow.camera.fov = 10
const light = new THREE.AmbientLight(0xffffff, 0.02) // soft white light
this.scene.add(light)
this.scene.add(spotLight)
}
初始化材质(重点)
对于这种混合渲染,需要把握的一点就是线框不能跟实体模型重合,需要把线框模型的scale 稍微大一些
initMaterial() {
this.material = new THREE.ShaderMaterial({
uniforms: {
// 基础模型scale 0.89
scale: { value: 0.89 }
},
vertexShader: require('./shaders/v.glsl').default,
fragmentShader: require('./shaders/f.glsl').default
})
this.frameMaterial = new THREE.ShaderMaterial({
uniforms: {
// 线框模型scale 1.09 > 0.89
scale: { value: 1.09 }
},
// 开启线框模式,这边 其实就是使用了 gl.LINES 的模式去渲染了
wireframe: true,
wireframeLinejoin: 'bevel',
wireframeLinewidth: 1.5,
vertexShader: require('./shaders/v.glsl').default,
fragmentShader: require('./shaders/fshow.glsl').default
})
}
这里我们初始话了两个材质对象,分别用于线框实体跟模型实体
添加场景对象
这里我们使用object.clone()的THREE 的OBject3D 对象的clone方法来对加载到的gltf数据对象进行深拷贝。
addModelToScene() {
try {
const object = this.modelObject.scene
// 基础实体模型
const normalObject = object.clone()
// normalObject.children.forEach(item => {
// item.material = this.material
// })
// 线框实体模型
const wireframe = object.clone()
// 遍历线框实体的Mesh,赋予线框的材质
wireframe.children.forEach(item => {
item.material = this.frameMaterial
})
// 初始话对象模型坐标位置
normalObject.position.set(-1, -1, 1)
wireframe.position.set(-1, -1, 1)
// 添加到场景
this.scene.add(normalObject)
this.scene.add(wireframe)
return true
} catch (e) {
return false
}
}
渲染循环
draw() {
this.scene.rotation.x = this.rotate[1]
this.scene.rotation.y = this.rotate[0]
this.render.render(this.curScene, this.camera)
// this.control.update()
requestAnimationFrame(this.draw)
}
mounted示例函数
这里我们使用Vue的组件,所以在挂在钩子添加
async mounted() {
this.initMaterial()
this.initRender()
this.initScene()
this.curScene = this.scene
const res = await this.loadModel(this.src)
this.modelObject = res
this.initLight()
// this.control = new OrbitControls(this.camera, this.render.domElement)
this.addModelToScene() && this.draw()
}
高能部分
shader实现
由于我们需要自定义效果,这边就需要用到Threejs 的 ShaderMaterial 构造函数来帮助我们使用的自定义的shader来进行模型的渲染
Uniform 变量定义
这边我们只使用了scale,因此只需要定义scale即可
this.frameMaterial = new THREE.ShaderMaterial({
uniforms: {
scale: { value: 1.09 }
},
wireframe: true,
wireframeLinejoin: 'bevel',
wireframeLinewidth: 1.5,
vertexShader: require('./shaders/v.glsl').default,
fragmentShader: require('./shaders/fshow.glsl').default
})
Shader 顶点着色器
编写着色器之前,我们首先了解下 ShaderMaterial跟 RawShaderMaterial 的区别,
Raw 顾名思义就是阉割了一些动西,可能需要你去实现的一些基础的工具函数或者变量。这对于想尽量优化shader代码的大牛使用。而ShaderMaterial的话threejs 会自带一些他自己的内建变量跟 方法,例如视图矩阵,投影矩阵,模型矩阵等等。本质上就是编译的时候在我们写的shader头部添加了一些他自己定义的变量跟函数,还有他的一些uniform attribute 的传入参数,初学者的化我们建议使用ShaderMaterial来实现
关于Threejs的内置变量请查阅WebGL
Program对象
代码如下
precision highp float;
varying vec3 v_Normal;
uniform float scale;
void main() {
// 把顶点的坐标乘以 scale 来进行中心缩放
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(position.xyz * scale, 1.0);
// 由于后面片元着色器需要顶点法线,定义一个v_Normal的法线插值
v_Normal = normalize(normalMatrix * normal);
}
Shader 片元着色器
关于片元着色器,我们首先实现一个简单的光照模型函数 如果要做出光渐变的效果。首先得理解模型法线
模型法线是模型中每个顶点跟周围顶点所构成的面微元的法向量
有了这个,我们还需要一个入射光线向量
那么反映该像素点的光亮程度的话我们就可以以两个向量的点积的方式来衡量,如果入射光线跟法向量夹角越小,证明入射光线几乎都能完全反射会摄像机,反之,则会往其他方向反射,摄像机代表的入射光线方向反方向接受到的光线就会越少,从而亮度更加暗。公式如下其中a向量代表入射光线,n代表模型中某一个顶点的法向量
有了以上条件 我们就可以写出片元的shader代码了
precision highp float;
// 顶点着色器传入的顶点插值后的法向量
varying vec3 v_Normal;
void main() {
// 定义入射光线,从屏幕往里,归一化
vec4 light = normalize(vec4(cameraPosition, 0.1));
// 计算该像素的光照强度 这里的2是倍率因子,可以是任何浮点数,可以看展示效果来相应做出调整
float density = dot(light.xyz, normalize(v_Normal.xyz)) * 2.;
// 使用mix 函数将两种颜色(紫色跟红色)按照光照强度混合,并且乘以光照强度的平方做出二次衰减的效果
// + vec3(0.2) 加上保底的颜色值,作为光线补偿,可以看展示效果来相应做出调整,避免暗部完全是暗的
gl_FragColor = vec4(mix(normalize(vec3(236., 72., 153.)), normalize(vec3(186., 85., 211.)), density - 0.4) * density * density + vec3(0.2), 1.0);
}
画龙点睛,跟随鼠标转动
这里我们定义了一个外部属性用于接受rotote值
props: {
src: {
type: String,
default: ''
},
rotate: {
type: Array,
default: () => [0, 0]
}
}
让后在上层组件监听鼠标函数修改传入即可
onMouseMove(e) {
const width = window.innerWidth
const height = this.$el.clientHeight
let rotateX = 0.5 + (e.offsetX / width) * -0.5
let rotateY = 0.5 + (e.offsetY / height) * -0.5
this.rotate[0] = rotateX
this.rotate[1] = rotateY
}
具体代码我放在gitee上面,各位可以上去获取gitee.com/zhou-jianhu…