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

7,946 阅读5分钟

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

技术栈

  • vite
  • typescript
  • threejs@0.152.2
  • less

2023-04-28 18.22.58.gif

最近丫丫回国掀起一股熊猫热,本人也没闲着,在查资料时候无意间发现 熊猫世界 的3D首页,心一痒痒,就扒了一下数据,刚好有模型,那索性按照熊猫世界的首页复刻一下;

本文没什么高的技术含量,完全觉得这个页面比较好看,所以大概做了做,涉及到的api光线投射RaycasterCSS 2D渲染器(CSS2DRenderer)TextureLoader 等等;

扒下来的数据分几个部分 背景图、模型、贴图、icon、旗帜、等

素材

image.png

这是地图的分层贴图

image.png

这是一些图标的图片

场景基础要素

设置场景背景贴图

scene = new Scene();
const sceneTexture = await loadTexture('../src/assets/textures/bg.jpeg')
sceneTexture.wrapS = THREE.RepeatWrapping;
sceneTexture.wrapT = THREE.RepeatWrapping;
sceneTexture.repeat.set(1, 1);
scene.background = sceneTexture

贴图加载公共方法

loadTexture是提取的一个公共方法,后续在加载earth模型的时候也需要用到,下面是提取的方法,返回一个Promise

// 加载纹理
export function loadTexture(grid: string) {
    return new Promise<Texture>((resolve, reject) => {
        textureLoader.load(grid, (texture) => {
            resolve(texture)
        });
    })
}

至于贴图的一些属性,我在之前的文章也有提到过,这里不赘述,可以看一下文档

渲染器

WebGLRenderer

renderer = new WebGLRenderer();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.needsUpdate = true;

CSS2DRenderer

2drenderer的作用是渲染国家旗帜用的,最后渲染出来的是一个element,在控制器渲染时,通过改变element的transform和position保证元素与3d向量对齐

labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.domElement.style.position = "absolute";
labelRenderer.domElement.style.top = "0";
labelRenderer.domElement.style.pointerEvents = "none";

image.png

灯光

// 灯光
ambientLight = new AmbientLight(0xffffff, 2.2);
scene.add(ambientLight)

用普通的环境光,能照亮场景就可以了,如果需要做一些其他氛围渲染,可以选用平行光或者点光源

控制器

controls = new OrbitControls(camera, renderer.domElement)

使用控制器可以改变相机的位置,可以从各个角度观察场景

复刻渲染

加载模型

earthGroup = new THREE.Group() 定义一个地球的组,将地球模型add进去,使用封装好的loadFbx方法将地图模型加载到场景中

 earth = await loadFbx('../src/assets/models/diqiu.fbx', async (event: ProgressEvent) => {
    const { total, loaded } = event
    const count = Math.min(Math.ceil(loaded / total * 100), 100)
    rendeDom(count)
    if (count === 100 && !scene) {
        loading.style.display = "none"
    }
})

方法支持两个属性,第一个属性为模型地址,字符串格式,第二个为加载中的回调,(event: ProgressEvent) => void类型

loadFbx

// 加载FBX模型
export function loadFbx(modelUrl: string, cb?: (event: ProgressEvent) => void): Promise<Object3D> {
    return new Promise((resolve, reject) => {
        fbxLoader.load(modelUrl, function (loadedModel) {
            resolve(loadedModel)
        }, (a) => {
            cb&&cb(a)
        });
    })
}

加载中的回调主要的作用就是加载进度条

地球贴图

// 地图贴图
const diqiuTexture = await loadTexture('../src/assets/textures/diqiu.jpeg')
earth.children[0].material.map = diqiuTexture

效果

image.png

渲染2d element

我在网站上没扒到国家位置的信息,而且在地球加载的过程中,原始网站提供的位置肯定跟咱们复刻的位置不会相同,所以另辟蹊径,从模型上下手

我的想法就是对照原始网站的大概位置,找到复刻的位置坐标,然后再渲染出2d元素,将位置设置到相应位置,利用鼠标点击射线,点击地图后,获取地图和鼠标的交叉点,说干就干~

提取坐标

let mouse = new THREE.Vector2(); //鼠标位置
var raycaster = new THREE.Raycaster();
window.addEventListener("click", (event) => {
    mouse.x = (event.clientX / document.body.offsetWidth) * 2 - 1;
    mouse.y = -(event.clientY / document.body.offsetHeight) * 2 + 1;
    raycaster.setFromCamera(mouse, camera);
    var raylist = raycaster.intersectObjects(earthGroup.children);
    console.log(raylist)
});

image.png

Raycaster射线方法在 官网 上也有一些案例,可以翻阅文档查看具体api

console.log(JSON.stringify({
    vector: new Vector3(raylist[0].point.x,
        raylist[0].point.y,
        raylist[0].point.z,),
    country: '日本',
    mapUrl: '../',
    iconUrl: '../'
}))

打印出组合的数据,这样就可以获取到具体的坐标,再组合一下其他数据,比如地图上的国家旗帜地址,列表的旗帜地址,或者直接存到localstore缓存中,我这里用的是最笨的方法,一个一个复制到文件里

image.png

添加元素

// 渲染2d文字
function initThreeFloorName(d: any) {
    var image = document.createElement("img");
    image.className = "country-image";
    image.setAttribute('src', d.mapUrl)
    if(d.style) {
        for(let key in d.style ) {
            const s = d.style[key]
            image.style[key] = s

        }
    }
    var earthLabel = new CSS2DObject(image);
    earthLabel.position.copy(d.vector);
    group2D.add(earthLabel);
}

group2D是存图标的组,每个元素都存在一个单独的组,后续好操作,

image.png

隐藏遮挡元素

从图中可以看到,地图正面背面的元素都展示在一起了,那是因为2d元素和3d元素不在同一个图层里,

image.png

这导致地图并不能遮挡到本应在地图后面的2d元素

那就需要在渲染的时候进行一些操作,来隐藏被遮挡的元素,还是利用射线;

从摄像机position发送一条射线,到2d元素的position,射线如果和地图及地图里的元素产生交叉,则隐藏当前2d元素,说干就干~

// 物体之间的射线
function rayMesh() {
    group2D.traverse((text: any) => {
        if (!text.isGroup) {
            const opt = pointRay(camera.position, text.position, sphereGroup);
            text.element.style.opacity = Number(!opt).toString();
        }
    });
}

pointRay 点到点射线交互

function pointRay(star, end, children) {
    let nstar = star.clone(); // 克隆一个新的位置信息,这样不会影响传入的三维向量的值
    let nend = end.clone().sub(nstar).normalize(); // 克隆一个新的位置信息,这样不会影响传入的三维向量的值

    const raycaster = new THREE.Raycaster(nstar, nend); // 创建一个正向射线
    const intersects = raycaster.intersectObjects(
        children.children,
        true
    );
    let jclang = 0
    let textlang = 0
    if (intersects.length != 0) {
        // 计算起点到交互点的曼哈顿距离
        jclang = star.distanceTo(intersects[0].point)
        // 起点到结束点的曼哈顿距离
        textlang = star.distanceTo(end)
    }
    return jclang < textlang;
}

image.png

一目了然,如果射线交叉点比终点的距离短,那视为隐藏 返回true 标记为有遮挡,

透明度降为0 text.element.style.opacity = Number(!opt).toString();

检测是否遮挡

// 渲染函数
function render() {
    renderer.render(scene, camera);
    labelRenderer.render(scene, camera);

    if (isUpdate) {
        isUpdate = false
        rayMesh()
        let timeout: any = setTimeout(() => {
            isUpdate = true
            clearInterval(timeout)
            timeout = null
        }, 100)
    }

}

render中调用rayMesh方法,我这里写的是一个简易的防抖,并不代表本人观点,哈哈哈(努力撇清关系),那么遮挡效果就做好了,还有图标的点击跳转功能,留给大家发挥,可以用射线检测,也可以用link跳转,随意

最终效果

2023-04-28 18.03.58.gif

祝大家五一快乐

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

历史文章

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