好久没写可视化方面的帖子了,最近在网上找灵感,发现一张图个人感觉不错,正好现有技术也可以实现,使用到的技术vite + typescript + threejs
相关源码和模型的下载链接地址点击链接进行跳转
灵感图
主要工作
加载模型
文中使用到的很多方法是之前文章有提过的,感兴趣的同学可以翻一下,使用之前写的gltfloader
方法将下载的模型加载出来,并使用模型中自带场景scene渲染3d世界
async function loadModel() {
const res = await loadGltf('../src/assets/models/机械零件/1/scene.gltf')
// 使用模型原有场景
scene = res.scene
init()
animate();
}
加载效果
镜头、灯光等参数
灯光采用的AmbientLight
环境光和DirectionalLight
平行光,将平行光位置设置在相机所在位置,并且在镜头转动时,实时修改平行光位置,这样可以保证模型始终能够被光线照到
const ambientLight = new AmbientLight(0xffffff, 40);
scene.add(ambientLight)
directionalLight = new THREE.DirectionalLight(0xffffff, 40);
directionalLight.position.copy(camera.position)
scene.add(directionalLight);
镜头采用的是 正交相机(OrthographicCamera
,一般独立展示小型模型并且没有空间关系的时候使用,正交相机和透视相机相比,通过不同的矩阵,实现不同的透视关系
const axesHelper = new THREE.AxesHelper(100);
const width = window.innerWidth, height = window.innerHeight
const offset = 500
camera = new THREE.OrthographicCamera(width / - offset, width / offset, height / offset, height / - offset, 0.001, 100000);
camera.position.copy(new THREE.Vector3(0, 0, -3))
控制器使用轨迹球控制器(TrackballControls)
,可以随意的操控场景,操控场景时监控相机位置,将平行光位置设置为相机位置
controls = new TrackballControls(camera, renderer.domElement);
// controls.zoom = 100
controls.rotateSpeed = 4.0;
controls.zoomSpeed = 1.2;
controls.panSpeed = 0.8;
controls.addEventListener('change', () => {
console.log(camera.position)
// 灯光位置跟随镜头改变
directionalLight.position.copy(camera.position)
})
创建线条
接下来的工作就是按照灵感图上的效果,做出线条,具体使用的api包含# 边缘几何体(EdgesGeometry)
,# 线段(LineSegments)
,# 基础线条材质(LineBasicMaterial)
,在loadModel
方法获取到模型后遍历模型,获取到组成大模型的零部件,
const lineGroup = new THREE.Group()
Sketchfab_model.traverse((mesh: THREE.Object3D) => changeModelMaterial(mesh, lineGroup))
scene.add(lineGroup)
遍历模型
遍历到每一个模型后调用changeModelMaterial
方法,这里具体的工作就是,获取模型的世界维度的信息,包含坐标,缩放比例以及四元数,为之后创建出来的线条使用,
export const changeModelMaterial = (object: THREE.Object3D,lineGroup: THREE.Group): any => {
const mesh: THREE.Mesh = object as any
if (mesh.isMesh) {
const quaternion = new THREE.Quaternion()
const worldPos = new THREE.Vector3()
const worldScale = new THREE.Vector3()
// 获取四元数
mesh.getWorldQuaternion(quaternion)
// 获取位置信息
mesh.getWorldPosition(worldPos)
// 获取缩放比例
mesh.getWorldScale(worldScale)
mesh.material.transparent = true
mesh.material.opacity = 0.4
// 以模型顶点信息创建线条
const line = getLine(mesh, 30, undefined, 1)
// 给线段赋予模型相同的坐标信息
line.quaternion.copy(quaternion)
line.position.copy(worldPos)
line.scale.copy(worldScale)
lineGroup.add(line)
}
}
由于traverse
的api的会将模型本身传到callback中,而咱们的模型是一个组,所以需要过滤一下,只按照Mesh模型创建线条
.traverse ( callback : Function ) : undefined
callback - 以一个object3D对象作为第一个参数的函数。
创建线条
let color = new THREE.Color('#0fb1fb')
const material = new THREE.LineBasicMaterial(
{
color: new THREE.Color(color),
depthTest: true,
transparent: true
}
)
export const getLine = (object: THREE.Mesh, thresholdAngle = 1, color = new THREE.Color('#ff0ff0'), opacity = 1): THREE.LineSegments => {
// 创建线条,参数为 几何体模型,相邻面的法线之间的角度,
var edges = new THREE.EdgesGeometry(object.geometry, thresholdAngle);
var line = new THREE.LineSegments(edges);
material.opacity = opacity
line.material = material
return line;
}
将线条材质写在方法的外部,统一调用,这样会保证每一个模型都复用同一个材质,不会创建新的,同时也会节省一点内存,能够优化一下性能,不过也有一个缺点,就是改变其中一个模型的样式,那么其他的都会被改变,下面做个实验
// 创建完线条,两秒后改变其中一个线条的颜色
setTimeout(()=>{
lineGroup.children[0].material.color = new THREE.Color(0x22ff00)
},2000)
刷新后,模型从蓝色 经过两秒变为绿色,从代码可以看出来,我只改变了lineGroup的第一个元素
模型发光
按照灵感图的样式,有模型本体时,线条不发光或者光比较弱,没有模型本体的时候,线条的光比较强,首先要先把线条搞出发光效果,提取官网提供的案例中的发光部分的代码,接下来开始操作
引用插件
// 渲染器通道
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass';
// 效果器
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer';
// 发光通道
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass';
// 输出通道
import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass';
// 着色器通道
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass';
添加通道和渲染器
这里大部分代码都是整理的官方案例的,具体详情可以查看源码,我其实也没整明白,帮不到大家
const params = {
threshold: 1,
strength: 1, // 强度
radius: 0.84,// 半径
exposure: 1.55 // 扩散
};
export const unreal = (scene:THREE.Scene,camera:THREE.OrthographicCamera,renderer:THREE.WebGLRenderer,width:number,height: number) => {
// 渲染器通道,将场景全部加入渲染器
const renderScene = new RenderPass(scene, camera);
// 添加虚幻发光通道
const bloomPass = new UnrealBloomPass(new THREE.Vector2(width, height), 1.5, 0.4, 0.85);
bloomPass.threshold = params.threshold;
bloomPass.strength = params.strength;
bloomPass.radius = params.radius;
// 创建合成器
const bloomComposer = new EffectComposer(renderer);
bloomComposer.renderToScreen = false;
// 将渲染器和场景结合到合成器中
bloomComposer.addPass(renderScene);
bloomComposer.addPass(bloomPass);
// 着色器通道
const mixPass = new ShaderPass(
// 着色器
new THREE.ShaderMaterial({
uniforms: {
baseTexture: { value: null },
bloomTexture: { value: bloomComposer.renderTarget2.texture }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
`,
fragmentShader: `
uniform sampler2D baseTexture;
uniform sampler2D bloomTexture;
varying vec2 vUv;
void main() {
gl_FragColor = ( texture2D( baseTexture, vUv ) + vec4( 1.0 ) * texture2D( bloomTexture, vUv ) );
}
`,
defines: {}
}), 'baseTexture'
);
mixPass.needsSwap = true;
// 合成器输出通道
const outputPass = new OutputPass();
const finalComposer = new EffectComposer(renderer);
finalComposer.addPass(renderScene);
finalComposer.addPass(mixPass);
finalComposer.addPass(outputPass);
return {
finalComposer,
bloomComposer,
renderScene,
bloomPass
}
}
通道的使用及渲染
在init初始化方法中调用unrela方法,将不同的通道和渲染器返回,并在render函数中调用
// 获取发光场景必要元素
const { finalComposer: F,
bloomComposer: B,
renderScene: R, bloomPass: BP } = unreal(scene, camera, renderer, width, height)
finalComposer = F
bloomComposer = B
renderScene = R
bloomPass = BP
bloomPass.threshold = 0
在render中调用
每一个通道的渲染,都需要在render 中循环调用
// 渲染函数
function render() {
controls && controls.update()
controls && controls.zoomCamera()
renderer.render(scene, camera);
// 渲染发光通道
if (bloomComposer) {
scene.traverse(darkenNonBloomed.bind(this));
bloomComposer.render();
}
// 渲染输出通道
if (finalComposer) {
scene.traverse(restoreMaterial.bind(this));
finalComposer.render();
}
}
function darkenNonBloomed(obj: THREE.Mesh) {
if (bloomLayer) {
if (obj.isMesh && bloomLayer.test(obj.layers) === false) {
materials[obj.uuid] = obj.material;
obj.material = darkMaterial;
}
}
}
function restoreMaterial(obj: THREE.Mesh) {
if (materials[obj.uuid]) {
obj.material = materials[obj.uuid];
// 用于删除没必要的渲染
delete materials[obj.uuid];
}
}
辅助元素gui
添加gui辅助观察具体参数是如何改变发光体的,从而找到合适的参数
function gui() {
const gui = new GUI();
const bloomFolder = gui.addFolder('bloom');
bloomFolder.add(params, 'threshold', 0.0, 1.0).onChange((value: number) => {
bloomPass.threshold = Number(value);
});
// 强度
bloomFolder.add(params, 'strength', 0.0, 3.0).onChange((value: number) => {
bloomPass.strength = Number(value);
});
// 半径
gui.add(params, 'radius', 0.0, 1.0).step(0.01).onChange((value: number) => {
bloomPass.radius = Number(value);
});
}
发光效果图
通过不同的参数调整,最终效果如下:
在之前的参数和配置中修改了相机,改为透视相机,看起来舒服点,符合人眼观察物体的近大远小关系
涡轮动起来
模型本身是带涡轮的动画剪切的,但是好像绑定了模型主体位置,加载自带的动画后,模型不在原本位置,所以舍弃了原有的动画
从模型自身找到涡轮,并在render函数中修改涡轮的rotation.x
// 涡轮
hull_turbine = scene.getObjectByName('hull_turbine')
hull_turbine_line = lineGroup.getObjectByName('hull_turbine_line')
// 涡轮的线
blades_turbine_003 = scene.getObjectByName('blades_turbine_003')
blades_turbine_003_line = scene.getObjectByName('blades_turbine_003_line')
let rotationX = 0.03
render函数中
// 旋转涡轮
if (hull_turbine && hull_turbine_line) {
hull_turbine.rotation.x += rotationX;
hull_turbine_line.rotation.x += rotationX;
}
if (blades_turbine_003) {
blades_turbine_003.rotation.x += rotationX;
}
blades_turbine_003_line && (blades_turbine_003_line.rotation.x += rotationX)
这个不是自己的模型,所以有很多不完美的地方,比如涡轮转的时候,有一根线一直没转,找了半天也没找到具体是那个元素的,所以就放着了,还有模型的中部,有特别密集的顶点,所以创建出来的线条也不是很美观,都变成一坨了,如果是自己的模型,完全可以规避这些问题,并且顶点信息太多,对页面的渲染也是一种压力
进阶功能
比如这真是一个涡轮监控系统,从物联网模型获得数据后,能够第一时间在模型上进行预警,好多大片里面都有这种物联网模型的监控,所以咱们设定一下,某个时间段,或者某个循环点,让模型的某个零件报警,以红色材质表现
纯线稿风格,有点赛博朋克的味道了~