修改ThreeJS动画模型visible属性致渲染卡顿的排查与解决
我在ThreeJS(r124版本)项目里,给一个GLB动画模型挂载了Spotlight,模拟带可开关灯光的摄像无人机。我编写了turnOnLight函数,仅通过设置灯光的visible属性为true来开启灯光:
this.turnOnLight = function(bUseLaserScan=false) { if (self.CAC.modelInstance.visible === false) self.CAC.modelInstance.visible = true; }
但每次开关灯光时,场景都会出现200-300毫秒的卡顿,渲染/动画循环直接暂停。如果注释掉修改visible属性的代码,卡顿就消失了。
疑问:为什么仅仅切换模型的visible属性会导致场景卡顿?该怎么解决?
如果是每次开关都加载大型模型,卡顿还能理解,但只是设置visible为true,为什么会出现这种问题?
以下是构建Spotlight并将其挂载到摄像无人机主动画对象的代码实现,其中self.CAC.modelInstance是我用于聚合所有动画模型通用ThreeJS对象和属性的对象:
function AnimatedLight( modelName, modelDeclJsonObj, threeJsLightObjectName='PointLight', topLevelAnimationModelObj=null, bAddLensSphere=false, bAddLaserScan=false) { const self = this; this._buildTheLight = function( threeJsLightObjectName, modelDeclJsonObj) { const methodName = self.constructor.name + '::' + `_buildTheLight`; const errPrefix = '(' + methodName + ') '; let retLightObj = null; if (misc_shared_lib.isEmptySafeString(threeJsLightObjectName)) throw new Error(`${errPrefix}The threeJsLightObjectName parameter is empty.`); if (!misc_shared_lib.isNonNullObjectAndNotArray(modelDeclJsonObj)) throw new Error(`${errPrefix}The modelDeclJsonObj is not a valid object.`); // 为modelDeclJsonObj中未定义的属性提供默认值 // 默认颜色为红色 let color = typeof modelDeclJsonObj.color !== 'undefined' ? modelDeclJsonObj.color : 0xf41321; // 0xffffff; let intensity = typeof modelDeclJsonObj.intensity !== 'undefined' ? modelDeclJsonObj.intensity : 8.6; // 1.0; let distance = typeof modelDeclJsonObj.distance !== 'undefined' ? modelDeclJsonObj.distance : 0.0; let decay = typeof modelDeclJsonObj.decay !== 'undefined' ? modelDeclJsonObj.decay : 1.0; // 这些属性仅适用于聚光灯,聚光灯有内角度 // 注意:我们在JSON模型初始化声明对象中存储角度,因为用角度比弧度更容易指定 let angle = (0.8 * MAX_ANGLE_FOR_SPOTLIGHT); // Math.PI / 3; // 默认值 if (typeof modelDeclJsonObj.inner_angle_in_degrees !== 'undefined') { if (typeof modelDeclJsonObj.inner_angle_in_degrees !== 'number') throw new Error(`${errPrefix} 模型JSON声明对象中的"inner_angle_in_degrees"属性不是数字。`); angle = THREE.MathUtils.degToRad(modelDeclJsonObj.inner_angle_in_degrees); } let penumbra = 0; if (typeof modelDeclJsonObj.penumbra_angle_in_degress !== 'undefined') { if (typeof modelDeclJsonObj.penumbra_angle_in_degress !== 'number') throw new Error(`${errPrefix} 模型JSON声明对象中的"penumbra_angle_in_degress"属性不是数字。`); // ThreeJS中半影角的范围是0到1.0,所以我们将提供的值除以180来重新缩放 penumbra = Math.min(THREE.MathUtils.degToRad(modelDeclJsonObj.penumbra_angle_in_degress / 180.0), 1); } // 根据指定的灯光对象类型构建正确的ThreeJS灯光对象 if (threeJsLightObjectName === 'PointLight') { retLightObj = new THREE.PointLight(color, intensity, distance, decay); } else if (threeJsLightObjectName === 'DirectionalLight') { retLightObj = new THREE.DirectionalLight(color, intensity); } else if (threeJsLightObjectName === 'SpotLight') { // 创建这种类型灯光的迷你菜单 retLightObj = new THREE.SpotLight(color, intensity, distance, angle, penumbra, decay); // 是否需要添加透镜球体? if (bAddLensSphere) { // 是的,创建它 // .................... 开始:子对象 - 透镜球体 ............ const radius = 3; self.lensSphereObj = new THREE.Mesh( new THREE.SphereBufferGeometry(radius, 20, 20), new THREE.MeshPhongMaterial({color: 0xFF0000})); // 将其添加到顶级动画模型 // self.CAC.modelInstance.add(cameraLensObj); // 将其添加到聚光灯对象 retLightObj.add(self.lensSphereObj); // .................... 结束:子对象 - 透镜球体 ............ } } else throw new Error(`${errPrefix}无效的threeJsLightObjectName值: ${threeJsLightObjectName}`); return retLightObj; } /** * 使灯光可见 */ this.turnOnLight = function(bUseLaserScan=false) { if (self.CAC.modelInstance.visible === false) self.CAC.modelInstance.visible = true; } /** * 使灯光不可见 */ this.turnOffLight = function() { if (self.CAC.modelInstance.visible === true) self.CAC.modelInstance.visible = false; } this.setTargetForSpotLight = function(targetObj) { self.CAC.modelInstance.target = targetObj; } /** * 必须调用此方法来初始化此动画灯光以进行动画 */ this.initializeModel = function ( parentAnimManagerObj, initModelArgs= null, funcCallWhenInitialized = null, ) { const methodName = self.constructor.name + '::' + `initializeModel`; const errPrefix = '(' + methodName + ') '; // 将拥有我们的AnimationManager()对象的引用存储在我们聚合的CommonAnimationObject中 self.CAC.parentAnimManagerObj = parentAnimManagerObj; // 从JSON声明块中的模型(灯光)声明创建ThreeJS灯光对象 self.CAC.modelInstance = self._buildTheLight(threeJsLightObjectName, initModelArgs); self.lensSphereObj.position.set( 0, 0, -20); // 动画灯光不使用模型加载器 self.CAC.modelLoader = null; // 设置初始位置 self.CAC.modelInstance.position.x = initModelArgs.initialPos_X; self.CAC.modelInstance.position.y = initModelArgs.initialPos_Y; self.CAC.modelInstance.position.z = initModelArgs.initialPos_Z; // 将模型添加到场景中 g_ThreeJsScene.add(self.CAC.modelInstance); // 完成初始化过程 self.CAC.initializeModelCompletely(null); // 执行所需的回调函数(如果有) if (funcCallWhenInitialized) funcCallWhenInitialized(self); }
原因分析
在ThreeJS中,修改Object3D.visible属性(包括灯光对象)会触发场景图的遍历和渲染状态的重新计算,尤其是当灯光挂载在复杂的动画模型(比如你的GLB无人机)下时:
- 渲染列表重建:当灯光从不可见变为可见时,ThreeJS需要将该灯光及其关联的阴影(如果开启)重新加入到渲染队列中,这会触发场景中所有受该灯光影响的物体的材质和光照计算的重新验证。
- 阴影贴图重建:如果你的SpotLight开启了阴影,切换
visible会导致阴影贴图重新生成,这在r124版本中是一个相对耗时的操作,尤其是阴影分辨率较高时。 - 动画模型的层级遍历:由于灯光挂载在GLB动画模型的层级结构中,修改
visible会触发整个模型层级的遍历和更新,额外增加了计算开销。
解决方案
方案1:用intensity替代visible控制灯光开关
这是最轻量化的方案,不需要修改场景结构:
this.turnOnLight = function(bUseLaserScan=false) { // 保存原始强度,避免重复设置 if (!self._originalIntensity) { self._originalIntensity = self.CAC.modelInstance.intensity; } self.CAC.modelInstance.intensity = self._originalIntensity; } this.turnOffLight = function() { if (!self._originalIntensity) { self._originalIntensity = self.CAC.modelInstance.intensity; } self.CAC.modelInstance.intensity = 0; }
这种方式不会触发场景图的大规模重建,只是修改灯光的强度值,性能开销极小。
方案2:控制灯光的castShadow和receiveShadow属性(如果开启了阴影)
如果你的灯光开启了阴影,可以在开关时同时控制阴影属性,减少阴影贴图的重建开销:
this.turnOnLight = function(bUseLaserScan=false) { self.CAC.modelInstance.visible = true; self.CAC.modelInstance.castShadow = true; } this.turnOffLight = function() { self.CAC.modelInstance.castShadow = false; self.CAC.modelInstance.visible = false; }
先关闭阴影再隐藏灯光,开启时先显示再打开阴影,避免不必要的阴影贴图计算。
方案3:将灯光从模型层级中移出,直接挂载到场景
如果不需要灯光跟随模型的动画,可以将灯光直接添加到场景中,通过更新灯光位置来同步模型位置,这样修改visible时只会影响单个灯光对象,不会触发模型层级的遍历:
// 在initializeModel中修改添加方式 // g_ThreeJsScene.add(self.CAC.modelInstance); // 改为挂载到场景,然后在动画循环中同步位置 g_ThreeJsScene.add(self.CAC.modelInstance); // 在动画循环中添加同步逻辑 function animate() { requestAnimationFrame(animate); // 假设无人机模型是topLevelAnimationModelObj self.CAC.modelInstance.position.copy(topLevelAnimationModelObj.position); self.CAC.modelInstance.quaternion.copy(topLevelAnimationModelObj.quaternion); }
方案4:升级ThreeJS版本
r124是相对较旧的版本,后续版本对灯光可见性切换的性能有优化。如果项目允许,可以考虑升级到较新的稳定版本(比如r150+),能直接缓解这类卡顿问题。
内容的提问来源于stack exchange,提问作者Robert Oschler

