threejs基础——判断物体遮挡方案

14 阅读6分钟

前言

在之前的文章,也提过很多次物体遮挡的案例,当时用的都是以THREE.Ray射线检测为基础,通过摄像头到目标体创建一条射线,通过检测射线是否经过被判断物体,从而决定被判断物体是否隐藏

其中这篇文章介绍的比较详细,感兴趣的同学可以看一看# threejs 打造 world.ipanda.com 同款3D首页

image.png

最近threejs更新了nodes,提供了WebGPURenderer,由于没有官方文档,所以相关的用法和api都需要通过阅读源码,增加了学习成本,今天就以nodes提供的一些api写一个另一种判断遮挡的方法。

R158版本中,Renderer提供了 isOccluded 方法,这是判断遮挡的核心方法,下面我们就一起实现一下,其中在遇到的坑也一起看一看,本篇文章比较长比较细,涉及大量源码,又不能把源码全部贴上来,所以过程可能有点跳。

PS:如对源码不感兴趣,可直接跳到实践章节

理论

升级

我是从R.155升级到R.157,以下内容就都将以这个版本开始

R.158的问题

image.png

源码中node_modules/three/examples/jsm/nodes/accessors/ModelNode.js

export const modelViewMatrix = nodeImmutable( ModelNode, ModelNode.VIEW_MATRIX ).temp( 'ModelViewMatrix' );

temp这个方法找不到,实在没找到具体怎么解决,有可能是版本太新,这里还有bug,所以退而求其次,降级到R.157了

R.157的问题

threejs这次更新,动作挺大,但是需要适配的地方也挺多的

image.png

我用的是vite框架,threejs中有一个源码文件中将await直接暴露在顶层,并没有包裹async,导致框架报错,所以要再安装一个支持await可以暴露再顶层的插件 vite-plugin-top-level-await

yarn add vite-plugin-top-level-await

vite.config.ts配置

import topLevelAwait from 'vite-plugin-top-level-await'

export default defineConfig({
   ...
    plugins: [topLevelAwait({
        promiseExportName: '__tla',
        promiseImportName: i => `__tla_${i}`
    })],
})

其中用到的有两个地方是直接将await暴露出来的,如果小伙伴有其他好的方式,也可以交流一下。

node_modules/three/examples/jsm/capabilities/WebGPU.js
node_modules/three/examples/jsm/renderers/webgpu/WebGPUBackend.js
if ( navigator.gpu !== undefined ) {

	_staticAdapter = await navigator.gpu.requestAdapter();

}

WebGPUBackend中增加了判断gpu适配器核心代码,所以我们在写代码时要提前判断一下,否则会报错。

hasFeature( name ) {

        const adapter = this.adapter || _staticAdapter;

        //

        const features = Object.values( GPUFeatureName );

        if ( features.includes( name ) === false ) {

                throw new Error( 'THREE.WebGPURenderer: Unknown WebGPU GPU feature: ' + name );

        }

        //

        return adapter.features.has( name );

}

实践

遮挡判断/createScene/index.ts文件中提供一个创建基础场景的类T,其中定义了摄像机、场景、控制器、灯光等

在构造函数中添加判断浏览器是否支持WebGPU的判断

if (WebGPU.isAvailable() === false && WebGL.isWebGL2Available() === false) {
        throw new Error('No WebGPU or WebGL2 support');
}

创建WebGPURenderer

在源码中,WebGPURenderer继承了THREE.Renderer,支持renderer的所有方法,所以可以大胆的使用class WebGPURenderer extends Renderer

import WebGPURenderer from 'three/examples/jsm/renderers/webgpu/WebGPURenderer';


createRenderer() {
    // 渲染器 THREE.WebGLRenderer
    this.renderer = new WebGPURenderer({
        antialias: true,
    });
    this.renderer.setPixelRatio(window.devicePixelRatio);
    this.renderer.setSize(this.width, this.height);
    this.renderer.setAnimationLoop(this.render.bind(this));
    this.dom.appendChild(this.renderer.domElement);

    this.renderCss2D = new CSS2DRenderer({ element: this.css2dDom });
    this.renderCss2D.setSize(this.width, this.height);
}

Renderer.jsAnimation.js提供了setAnimationLoop方法,在Animation.js里调用,这样不用手动写requestAnimationFrame方法。当然了,实现原理是样的node_modules/three/examples/jsm/renderers/common/Animation.js中提供的Animation.start()也是通过调用requestAnimationFrame来实现的

所以我们将自己写的render方法放在setAnimationLoop的回调,即可this.renderer.setAnimationLoop(this.render.bind(this));

创建元素

渲染器配置好以后,就可以创建object3d了,材质要选支持node属性的MeshBasicNodeMaterial,nodes/materials支持, 值得注意的是MeshPhongNodeMaterial Phong网格材质在使用过程中也有一个问题,

image.png

某一个texture的size不符合预期,导致报错,并且页面不渲染,所以这里先规避一下这个问题,选用其他的材质,或许某个机缘巧合会找到合适的解决方案。

创建网格

// 平面几何体
const planeGeometry = new THREE.PlaneGeometry(2, 2);
// 创建一个平面网格
const plane = new THREE.Mesh(planeGeometry, new MeshBasicNodeMaterial({ color: 0x00ff00}));
T.scene.add(plane)
// 创建一个球几何体
const sphereGeometry = new THREE.SphereGeometry(0.5);
// 创建球的网格
const sphere = new THREE.Mesh(sphereGeometry, new MeshBasicNodeMaterial({ color: 0xffff00 }));
sphere.position.z = -2
T.scene.add(sphere)

使用node材质创建出来的网格比普通网格材质多以下几个可控制参数backdropNodecolorNodeopacityNodepositionNode等,后续会用到

css2dObject

为了方便,直接拿 官网案例创建的元素,并加入球网格内

// 创建一个div元素
const moonMassDiv = document.createElement('div');
moonMassDiv.className = 'label';
moonMassDiv.textContent = '7.342e22 kg';
moonMassDiv.style.color = '#fff'
moonMassDiv.style.backgroundColor = 'transparent';

moonMassLabel = new CSS2DObject(moonMassDiv);
moonMassLabel.position.set(0, 0.8, 0);
moonMassLabel.layers.set(1);
sphere.add(moonMassLabel)

效果

image.png

遮挡判断

创建好需要的元素以后,接下来将已有元素加入nodes,并进行遮挡判断

创建遮挡实例

遮挡实例用nodeObject 创建,export function nodeObject<T extends NodeObjectOption>(obj: T): NodeObject<T>;,接受一个node实例。

NodeObjectOption:

/** anything that can be passed to {@link nodeObject} and returns a proxy */
export type NodeRepresentation<T extends Node = Node> = number | boolean | Node | Swizzable<T>;

/** anything that can be passed to {@link nodeObject} */
export type NodeObjectOption = NodeRepresentation | string;

node_modules/three/examples/jsm/nodes/core/Node.js这个文件代码可以看到,node的update方法只打印出一个warn,找到它对应的类型标注文件node_modules/@types/three/examples/jsm/nodes/core/Node.d.ts,Node实例update方法需要重写

Node.js

update( /*frame*/ ) {

        console.warn( 'Abstract function.' );

}

Node.d.ts

/** This method must be overriden when {@link updateType} !== 'none' */
update(frame: NodeFrame): void;
image.png

这样的话 我们还需要重写一个类,继承Node并传入到nodeObject中,有了之前对源码的参考,对以下代码的理解可能更深入一些。

class OcclusionNode extends Node {
    uniformNode:Swizzable;
    testObject:THREE.Object3D; 
    normalLayer:number;
    occludedLayer: number
    constructor(testObject:THREE.Object3D, normalLayer:number, occludedLayer:number) {
        super('vec3');
        /** This method must be overriden when {@link updateType} !== 'none' */
        // 必要代码
        this.updateType = NodeUpdateType.OBJECT;

        // uniform 是 GLSL 着色器中的全局变量。
        this.uniformNode = uniform(1);

        this.testObject = testObject;
        this.normalLayer = normalLayer;
        this.occludedLayer = occludedLayer;

    }

    async update(frame:NodeFrame) {
        if(frame.renderer) {
            // 更新时通过render判断被检测物品是否被遮挡
            const isOccluded = (frame.renderer as Renderer).isOccluded(this.testObject);
            // 如果被遮挡,取之前存的被遮挡的值,如果没有被遮挡,取为被遮挡的值
            const val = isOccluded ? this.occludedLayer : this.normalLayer
            // 修改label的层级位置
            moonMassLabel && moonMassLabel.layers.set(val)
        }
    }

    setup( /* builder */) {

        return this.uniformNode;

    }

}

上面的类定义了一个重写了更新方法,在更新方法中,通过isOccluded方法判断textObject是否被遮挡,通过new OcclusionNode时传入的第二和第三个函数,决定最后label的layer取哪个值

// 创建一个遮挡实例
const instanceUniform = nodeObject( new OcclusionNode( sphere, 1, 0 ) );

sphere.material.opacityNode = instanceUniform

sphere.occlusionTest = true;

sphere.material.opacityNode上述代码用到的node属性是opacityNode,因为这个参数可以接受一个数字变量,而其他比如colorNode接受的是一个THREE.Color实例,然而我们在update中决定是否对元素进行隐藏的时候,用的是label的layer属性,然而layer并没有提供layerNode,所以这可能是一个小瑕疵,layer的原理就是物体在和当前使用的camera同一个层级即可见。

最终效果

2023-11-29 10.36.27.gif

源码地址

历史文章

# threejs开发可视化数字城市效果 # threejs渲染高级感可视化涡轮模型 # 写一个高德地图巡航功能的小DEMO

# Javascript基础之有趣的文字效果

# Javascript基础之写一个好玩的点击效果

# Javascript基础之鼠标拖拉拽

# three.js 打造游戏小场景(拾取武器、领取任务、刷怪)

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

# three.js——物理引擎

# three.js——镜头跟踪

# threejs 笔记 03 —— 轨道控制器

结语

相对以前的文章,这次的文章也是一个创新性的方向,并没有期待它有多么的受欢迎。内容没有简单粗暴的直接写页面效果,而是通过一个小小的功能,从源码角度深度剖析实现原理;阅读源码的能力是程序员成神的必经之路,以后出门跟其他前端聊天也有谈资,好了,就到这了,我要摸鱼了~~