threejs渲染高级感可视化涡轮模型

13,415 阅读7分钟

好久没写可视化方面的帖子了,最近在网上找灵感,发现一张图个人感觉不错,正好现有技术也可以实现,使用到的技术vite + typescript + threejs

相关源码和模型的下载链接地址点击链接进行跳转

灵感图

image.png

主要工作

加载模型

文中使用到的很多方法是之前文章有提过的,感兴趣的同学可以翻一下,使用之前写的gltfloader方法将下载的模型加载出来,并使用模型中自带场景scene渲染3d世界


async function loadModel() {
    const res = await loadGltf('../src/assets/models/机械零件/1/scene.gltf')
    // 使用模型原有场景
    scene = res.scene

    init()
    animate();
}

加载效果

2023-10-27 18.04.38.gif

镜头、灯光等参数

灯光采用的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)
2023-11-15 10.25.40.gif

刷新后,模型从蓝色 经过两秒变为绿色,从代码可以看出来,我只改变了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辅助观察具体参数是如何改变发光体的,从而找到合适的参数

image.png
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);

    });

}

发光效果图

通过不同的参数调整,最终效果如下:

image.png

image.png

在之前的参数和配置中修改了相机,改为透视相机,看起来舒服点,符合人眼观察物体的近大远小关系

涡轮动起来

模型本身是带涡轮的动画剪切的,但是好像绑定了模型主体位置,加载自带的动画后,模型不在原本位置,所以舍弃了原有的动画

image.png

从模型自身找到涡轮,并在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)

2023-11-15 17.28.15.gif

这个不是自己的模型,所以有很多不完美的地方,比如涡轮转的时候,有一根线一直没转,找了半天也没找到具体是那个元素的,所以就放着了,还有模型的中部,有特别密集的顶点,所以创建出来的线条也不是很美观,都变成一坨了,如果是自己的模型,完全可以规避这些问题,并且顶点信息太多,对页面的渲染也是一种压力

进阶功能

比如这真是一个涡轮监控系统,从物联网模型获得数据后,能够第一时间在模型上进行预警,好多大片里面都有这种物联网模型的监控,所以咱们设定一下,某个时间段,或者某个循环点,让模型的某个零件报警,以红色材质表现

2023-11-15 18.02.26.gif

纯线稿风格,有点赛博朋克的味道了~

image.png

相关源码和模型的下载链接地址点击链接进行跳转

历史文章

# 写一个高德地图巡航功能的小DEMO

# threejs 打造 world.ipanda.com 同款3D首页