在公司做B端都快做吐了,忙里偷闲做一些自己想做的效果,也是一种摸鱼,欢迎大家收藏点赞关注
前言
之前写的threejs文章都是通过模型加载,然后进行一些交互处理,然而前端不能只依靠3d设计师或者网上的素材,所以这篇文章以前端的基础svg,0帧起手,创造一个属于自己的大楼,文章主要内容包含svg的加载、根据路径绘制楼块,制作材质,之前一些文章着重点有在动画的,有在交互的,本文会着重介绍几款材质。
技术栈
- "three": "0.167.0",
- "typescript": "^5.0.2",
- "vite": "^4.3.2"
效果图
------------- 正文分割线 -------------
处理SVG
制作svg
先用Illustrator
绘制出svg图,为了方便展示图层我在ps中打开大概看一下,svg主要有三部分,外墙和地板:outwall_1
,房间room_
其他的为内部墙面,为了世界的美好,和UI或者3d设计师沟通的时候一定要提前沟通名称的命名,不然前端什么也不知道;如果有孔洞需要加载,也需要提前沟通。
加载svg
threejs提供了SVGLoader,使用起来也特别简单,提供一个load方法用于加载svg,
load ( url : String, onLoad : Function, onProgress : Function, onError : Function ) : undefined
- url --- 一个
.svg
文件的路径或者网络地址。 - onLoad --- (可选的)加载成功之后调用的函数。该函数以ShapePath为参数。
- onProgress --- (可选的)正在加载时调用的函数。参数为XMLHttpRequest实例,包含total和loaded。 如果服务没有设置Content-Length头,.total的值为0。
- onError --- (可选的)加载出现错误时调用的函数。该函将错误信息作为参数。
除此之外,还提供一个静态方法
.createShapes ( shape : ShapePath ) : Array
shape --- 一个ShapePath的矢量图形数组,指定SVGLoader的onLoad函数的参数。
返回一个或多个基于shape : ShapePath的Shape对象,并作为该函数的一个参数返回。在处理shape时候需要用到。
以此封装一个方法,返回一个promise,返回加载好的svg。
typescript
export function loadSvg(url: string): Promise<any> {
return new Promise((resolve, reject) => {
svgLoader.load(url, (data) => {
resolve(data)
}, (load: any) => {
console.log(load);
}, (error) => {
console.log(error);
reject({error})
})
})
}
...
const svg = await loadSvg(`${import.meta.env.VITE_ASSETS_URL}assets/svg/bulding.svg`);
svg加载完成时,开始解析svg内容,
还记得前面svg图层上的名字么,解析出来作为id存在userdata中,通过这个类型来区分具体如何去处理对应的path,使用SVGLoader.createShapes
将path路径解析为形状,再用挤压缓冲几何体(ExtrudeGeometry)将形状的高度拉出来,这样我们就能通过路径获取到形状,再获取到几何体
typescript
for (const path of svg.paths) {
// 通过路径获取形状
const shapes = SVGLoader.createShapes(path);
const id = path?.userData?.node.id;
if (id && id.includes('outwall_')) {
for (const shape of shapes) {
... 处理外墙
}
} else if (id && id.includes('room_')) {
... 处理房间
} else {
for (const shape of shapes) {
... 处理内部墙体
}
}
}
挤压缓冲几何体
在循环中获取到shape
后,使用缓冲挤压几何体将形状的高度挤出来,代码中封装了获取挤压几何体的方法,通过输入不同的参数获得不同的效果。
typescript
// 设置挤出几何体
/**
* 创建挤出几何体
* @param shape 形状
* @param options 挤出设置
* @param material 材质
* @returns 挤出几何体
*/
export const createExtrudeGeometry = (shape: THREE.Shape, options: THREE.ExtrudeGeometryOptions = {}, material: THREE.Material = glassMaterial) => {
// 定义挤出设置
const wallSetting = {
steps: 1, // 挤出步骤数
depth: wallH, // 挤出深度
bevelEnabled: false, // 启用斜角
...options
};
// 挤压缓冲几何体
const geometry = new THREE.ExtrudeGeometry(shape, wallSetting);
const mesh = new THREE.Mesh(geometry, material);
return mesh
}
这里需要注意一下,渲染后的挤压模型并不是在屏幕中央的 所以需要处理一下,角度转成和svg的方向一致并且要将模型放置在中央,后续增加楼层的时候再进行y轴的分配;
typescript
const group = new Group();
group.name = 'floor_1'
scene.add(group);
group.rotation.x = Math.PI / 2;
const center = new Vector3()
const box3 = new Box3();
box3.setFromObject(group)
box3.getCenter(center)
group.position.copy(center.clone().negate());
将所有处理过的挤压几何体都放到一个组中,统一进行管理,在后续做交互的时候也会清晰一些。
调用createExtrudeGeometry
时,材质参数先随便传一个进去,后面会针对材质专门讲解。先看看加载后的效果:
从加载后的模型可以看出来两个问题,第一是玻璃外墙和墙体的面重叠,导致栅格了,第二个是地板材质的颜色和效果没展示出来,这些将在介绍材质的时候处理掉。
材质
从效果图上看,有墙体,外墙,和地板这三种材质,地板材质是一个近似于渐变色的效果,四周有光晕,外墙效果类似玻璃的效果,可以透到里面看到墙面,而且还有反光,而内部的墙体就是一个比较简单的纯色材质,但是接受阴影并产生阴影,分析完大致的效果就可以开工了
地板材质
地板的发光效果,使用渐变模拟,从模型中心径向渐变,向外扩散另一个颜色,所以要使用到着色器材质ShaderMaterial
,先从比较复杂的地板材质开始,为了方便查看效果和区别模拟了两个立方体,一个是用BoxGeometry
做的常规Mesh,另一个是由点位信息挤压出来的ExtrudeGeometry
模型,用相同的材质,看一下两者的差别,下面是着色器的主体,有几个关键的位置,center为扩散的中心位置,定义为vec2(0.5, 0.5)
在模型的正中间开始,orange
扩散的起始颜色,pink
扩散的结束颜色,计算两个颜色的差值,并渲染到UV面上
创建挤压几何体
typescript
// 创建一个示例网格
const w = 50
const shape = new Shape();
shape.moveTo(-w, -w);
shape.lineTo(w, -w);
shape.lineTo(w, w);
shape.lineTo(-w, w);
shape.lineTo(-w, -w);
const extrudeSettings = {
steps: 1,
depth: -w,
bevelEnabled: false
};
const geometry = new ExtrudeGeometry(shape, extrudeSettings);
const mesh = new Mesh(geometry, material);
scene.add(mesh);
创建着色器
typescript
const material = new ShaderMaterial({
uniforms: {
uTime: { value: 0.0 }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
varying vec2 vUv;
void main() {
// 计算到中心点的距离
vec2 center = vec2(0.5, 0.5);
float dist = distance(vUv, center);
// 创建径向渐变效果,从橙到粉
float gradient = smoothstep(0.0, 0.8, dist);
vec3 orange = vec3(1.0, 0.65, 0.0);
vec3 pink = vec3(1.0, 0.41, 0.71);
// 在两个颜色之间进行插值
vec3 color = mix(orange, pink, gradient);
gl_FragColor = vec4(color, 1.0);
}
`,
});
左边的box模型已经渲染出来渐变的效果,右侧的挤压几何体并没有渲染出渐变的效果,这是因为拉伸形状时,侧面和顶面 / 底面的顶点连接方式可能导致着色器无法正确地插值属性,并没有正确的按照几何体的UV面进行渲染,纹理坐标没有正确地分配给新生成的顶点,着色器就不能正确地采样纹理,所以我们要重新映射一下UV面,好让着色器能够正确的渲染:
typescript
// 手动设置UV坐标以实现整面渐变效果
const uvs = [];
const positions = geometry.attributes.position.array;
for (let i = 0; i < positions.length; i += 3) {
// 将3D坐标映射到0-1的UV空间
const x = (positions[i] + w) / (2 * w);
const y = (positions[i + 1] + w) / (2 * w);
uvs.push(x, y);
}
// 重新设置geometry的UV属性
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2));
经过UV面的处理可以看到uv的属性都映射为0-1的uv空间了
着色器的颜色定义
着色器的颜色设定和常规的16进制或者rgb的都不同,vec3(1.0, 0.65, 0.0)
以橘色为例,#FF69B4 -> R: 255/255=1.0, G: 105/255=0.41, B: 180/255=0.71
,需要将16进制转化为rgb,再除以255,得到一个0-1的数值,知道此原理了,就可以写一个公共方法,传入16进制的颜色后,输出着色器使用的vec3
typescript
// 将16进制颜色转换为vec3字符串
export const hex2Vec3String = (hex: string) => {
// 移除#号(如果有)
hex = hex.replace('#', '');
// 将16进制转为RGB
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
// 返回vec3格式字符串
return `vec3(${r.toFixed(2)}, ${g.toFixed(2)}, ${b.toFixed(2)})`;
}
···
vec3 color1 = ${hex2Vec3String('#123566')};
vec3 color2 = ${hex2Vec3String('#0f9dfe')};
着色器动态属性
如果你不想这样去转换,也可以将颜色、渐变范围、中心点当做变量提取出来,并在render中进行改变,这样可以做出动态效果
typescript
uniforms: {
...
color1: { value: new Color('#ffffff') },
color2: { value: new Color('#000000') },
center: { value: [0.5, 0.5] },
// 定义变量
gradientRange: { value: 0.9 }
},
fragmentShader: `
...
uniform vec3 color1;
uniform vec3 color2;
uniform vec2 center;
uniform float gradientRange;
float unpackDepth(const in vec4 rgba_depth) {
...
void main() {
float dist = distance(vUv, center);
float gradient = smoothstep(0.0, gradientRange, dist);
// 用变量替换之前的颜色
// vec3 color1 = ${hex2Vec3String('#ffffff')};
// vec3 color2 = ${hex2Vec3String('#000000')};
vec3 color = mix(color1, color2, gradient);
}
`,
在render中修改着色器的扩散范围属性
typescript
radialGradientMaterial.uniforms.gradientRange.value = 0.4 + Math.sin(Date.now() * 0.001) * 0.4;
就能做到下面这个呼吸灯的效果
着色器的复杂 不是一句两句能说清的,最近的实战案例都有涉及着色器的开发,可以参考来看 ,也可以参考学习网站一点点学
多材质挤压几何体
为了体现楼层的感觉,面我们用前面做的径向渐变的着色器材质,而侧面,则用另一种黑色的材质,表示楼板的高度,Mesh
支持向material添加多个材质,在定义挤压缓冲几何体的方法内添加一个可选参数sideColor
,当这个参数存在的时候,创建另一个材质并赋值给当前mesh
typescript
// 设置挤出几何体
/**
* 创建挤出几何体
* @param shape 形状
* @param options 挤出设置
* @param material 材质
* @returns 挤出几何体
*/
export const createExtrudeGeometry = (shape: Shape, options: ExtrudeGeometryOptions = {}, material: Material = glassMaterial, sideColor?: Color) => {
...
const mesh = new Mesh(geometry, material);
if (sideColor && mesh.material) {
mesh.material = [material, new MeshStandardMaterial({ color: sideColor, side: BackSide })] as any;
}
return mesh
}
墙体材质
内部墙体的材质就相对比较简单的,只要添加颜色就可以了,但是选择材质需要选择可以生成阴影的材质MeshPhysicalMaterial
,
typescript
// 定义墙体材质
export const wallMaterial = new MeshPhysicalMaterial({
name: "Radial_Engine_To_Skfb_Orange_Mat",
color: 0x0069ff, // 颜色
roughness: 0.46779725609756095, // 粗糙度
side: 2, // 面
metalness: 0, // 金属度
sheen: 1, // 光泽
...
});
除了颜色,其他都属于微调,比如感觉在当前灯光下,感觉材质太亮了或者太暗了,可以根据需求调整roughness
粗糙和金属metalness
,如果金属和光泽调低,则材质表面属于漫反射,如果你想做出五彩斑斓的黑色,也可以根据iridescence
虹彩、iridescenceIOR
折射率,iridescenceThicknessRange
虹彩范围等属性来调整,在项目中,内部墙体只需要一个简单的蓝色材质稍微带点光泽即可,因为外面还需要套一层半透明的玻璃外墙材质,所以光泽可适当高一点,不然被玻璃遮挡以后就看不出效果了
外墙材质
外墙材质做成玻璃的效果,有透明度,有反光,有倒影(镜像)顺便再用Line2
制作出黑色窗框的效果,从下面的动图可以看出来内部的墙体可以展现出来,并且在转动镜头的时候,灯光打在玻璃上会有明显的光泽,但是加上玻璃外墙效果以后,整体楼层的饱和度降低了很多,后续可以在收尾的时候加一些灯光或者其他特殊处理让画面整体的饱和度调高。
玻璃材质
玻璃材质效果只使用MeshStandardMaterial
标准网格材质模拟的,和内部墙面一样,只是通过参数调整表现出半透明的玻璃效果,通过调整透明度、粗糙度、金属这几个参数,得到一个最终半透明带反光效果的材质。
typescript
export const glassMaterial: any = new MeshStandardMaterial({
color: 0x0c2c5d,
"roughness": 0, // 粗糙度
"metalness": 0, // 金属度
"blending": 1, // 混合模式
"side": 2, // 渲染哪一面
"opacity": 0.4, // 不透明度
"transparent": true, // 是否透明
...其他属性
}
倒影
按照效果图的表现,楼体下面还存在一个自身的倒影,所以我们要在前面写的地板代码中做一些处理。这里就可以使用threejs官网提供的镜面效果案例,代码也比较简单,从官网提供的案例 "去"其精华"取"其糟粕,只总结下来这几行代码,并封装成一个函数:
typescript
import { BufferGeometry} from "three";
import { Reflector, ReflectorOptions } from "three/examples/jsm/Addons.js";
export const createMirror = (geometry: BufferGeometry, options?: ReflectorOptions) => {
const groundMirror = new Reflector(geometry, {
clipBias: 0.3,
textureWidth: window.innerWidth * window.devicePixelRatio,
textureHeight: window.innerHeight * window.devicePixelRatio,
...options,
});
return groundMirror
}
接受两个参数,第一个是镜面的几何图形,第二个参数就是具体配置镜面参数的属性了,我们给几个默认的参数,贴图尺寸和反射器裁剪偏差,其余参数可以通过源代码中类型ReflectorOptions
获取,如果想要在这个基础上添加shader着色器的话,也是提供了shader
的配置。clipBias用于调整反射器的剪裁偏差,值越小,反射效果越明显,适用于需要精细反射的场景。此参数可以帮助减少光线在反射表面上的干扰,确保反射效果更加真实。通过设置clipBias,可以有效避免在某些情况下出现的视觉伪影,提升整体渲染质量。 用人话说就是数值越小,镜面效果越精细。项目中默认给0.3,我们是在地板上做反光,不至于像镜子那样精细,而且,这个镜面效果特别消耗性能。
性能对比
未加镜面效果 6个svg生成的楼层,fps能跑到35-60;
添加镜面效果
同样楼层数量,fps只能跑到3-4。所以为了性能考虑,取消镜面效果,但是又感觉内部楼体比较单薄,没有层次感,所以还需要为楼体添加阴影。后文加上
玻璃外框
玻璃外框的横线从svg获取到的shape
生成ShapeGeometry
形状缓冲体,获取到形状缓冲体的顶点信息,并赋值给生成的Line2,颜色设置为黑色,这样整体横向的线就制作完成了,纵向线段是从定点信息获取到定点位置,设为起点,将内部楼体的高度设为结束点,并将Line2的宽度设为2或者0.2有一种错落有致的感觉,生成多个线段,将横向和纵向所有的线放置在同一个组内,并添加到场景中。
typescript
/** 定义一个导出的函数getLineByShape,接收一个Shape类型的参数 */
export const getLineByShape = (shape: Shape) => {
/** 使用传入的shape创建一个新的ShapeGeometry对象 */
const geometry = new ShapeGeometry(shape);
/** 从几何体中获取position属性,包含所有顶点的位置数据 */
const positions = geometry.getAttribute('position')
/** 创建一个新的LineGeometry对象,用于存储线条的几何信息 */
const lineGeometry = new LineGeometry();
/** 将位置数组设置为LineGeometry的顶点位置 */
lineGeometry.setPositions(positions.array as Float32Array);
/** 创建一个新的Group对象,用于管理多个线条对象 */
const lineGroup = new Group()
/** 使用lineGeometry和默认的材质matLine创建一个Line2对象 */
const line = new Line2(lineGeometry, matLine());
/** 设置线条对象在z轴上的位置为outWallH */
line.position.z = outWallH
/** 将创建的线条对象添加到Group组中 */
lineGroup.add(line)
/** 遍历所有位置点,根据条件创建竖直方向的线条 */
for (let i = 0; i < positions.count; i++) {
/** 从位置属性中获取第i个顶点的坐标,并创建一个Vector3对象 */
const start = new Vector3().fromBufferAttribute(positions, i);
/** 克隆start向量并在z轴方向上添加outWallH,得到线条的结束点 */
const end = start.clone().add(new Vector3(0, 0, outWallH))
/** 创建一个新的Line2对象,设置起点和终点的位置,并应用相应的线条材质 */
const line2 = new Line2(
new LineGeometry().setPositions([start.x, start.y, start.z, end.x, end.y, end.z]),
matLine(i % 4 === 1 ? 2 : 0.2, '#031526')
)
/** 将新创建的线条对象添加到Group组中 */
lineGroup.add(line2)
}
/** 返回包含所有线条的Group组对象 */
return lineGroup
}
阴影
由于地板添加了渐变着色器,而着色器如果要接受阴影,需要单独计算,凡是涉及到计算的都会消耗一定的性能,所以我们这里取巧一下,不用着色器接受阴影,而是创建一个阴影材质,放到楼层组,专门用来接受阴影,就像先前写的物理引擎小汽车一样,用阴影作为背景,既能显示出阴影又不遮挡其他的元素。下面这是创建阴影材质的函数,输出一个Plane的平面几何体,接受一个Object3D,用于设定plane的尺寸和位置。如果全场景只需要一个底面作为阴影接受面,可以直接设置plane的size。并不需要通过mesh 计算。
typescript
// 创建一个导出名为createShadowPlane的函数,并接收一个Object3D类型的参数mesh
export const createShadowPlane = (mesh: Object3D) => {
// 创建一个新的阴影材质,设置透明度为0.9和透明属性
var shadowMaterial = new ShadowMaterial({ opacity: 0.9, transparent: true });
// 创建一个Box3对象用于计算mesh的边界盒
const box3 = new Box3();
// 将mesh对象的范围设置到box3中
box3.setFromObject(mesh)
// 创建一个Vector3对象用于存储大小
const size = new Vector3();
// 获取box3的大小并存储到size中
box3.getSize(size)
// 创建一个Vector3对象用于存储中心点
const center = new Vector3();
// 获取box3的中心点并存储到center中
box3.getCenter(center)
// 创建一个使用BoxGeometry的平面网格对象,基于size的x和z,y方向厚度为0.5,使用shadowMaterial
const plane = new Mesh(new BoxGeometry(size.x, 0.5, size.z, 1, 1, 1), shadowMaterial);
// 设置plane接收阴影
plane.receiveShadow = true;
// 设置plane的位置,基于中心点的y坐标减去半个高度加1
plane.position.copy(center.clone().setY(center.y - size.y / 2 + 1))
// 返回创建的plane对象
return plane
}
阴影效果图
至此。材质部分算是写完了,后续如果需要添加交互,可以参照之前写过的文章为场景添加点击事件,通过射线检测得到当前点击的楼层,进行下一步操作。