Three.js模型加载与截图踩坑记录
Wucheng

需求背景与问题

公司要求做一个能够不同角度查看 GLB 模型并能够获取到截图,根据粗模的截图后续交由Stable Diffusion进行渲染。

目前采用的控制器为 PointerLockControls,后续不确定是否重构为 FlyControls进行第一人称控制,现在是自行监听键盘。

功能开发后发现了两个问题

  1. 获取canvas渲染的图像是黑屏的
  2. 截图交给SD于插件识别轮廓进行 ControlNet 生成渲染图片,但由于光照问题导致物体识别效果不好,没有识别出轮廓线

基本代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
<template>
<div class="relative w-full">
<canvas ref="containerRef" class="relative w-full h-full aspect-video ..." />
</div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { PointerLockControls } from "three/addons/controls/PointerLockControls.js";
import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";

const props = withDefaults(defineProps<Props>(), {
modelUrl: "",
});

/**
* Three.js 核心实例
*/
/** 场景对象 */
let scene: THREE.Scene;
/** 透视相机 */
let camera: THREE.PerspectiveCamera;
/** WebGL渲染器 */
let sceneBackground: string = "#cfcfcf";
let renderer: THREE.WebGLRenderer;
/** 第一人称控制器 */
let controls: PointerLockControls;
/** 当前加载的模型组 */
let loadedModel: THREE.Group | null = null;
/**
* 方向向量缓存(复用避免频繁创建对象,提升性能)
*/
const _direction = new THREE.Vector3();
const _right = new THREE.Vector3();
const _moveVector = new THREE.Vector3();

/**
* 初始化Three.js场景、相机、渲染器和控制器
*/
function initScene() {
....
}

/**
* 加载3D模型文件
* @param url - 模型文件URL(.glb/.gltf格式)
*/
function loadModel(url: string) {
// URL有效性检查
if (!url || !url.trim()) {
console.warn("模型URL为空,跳过加载");
isLoading.value = false;
return;
}
const loader = new GLTFLoader();
// 配置Draco压缩解码器(用于加载压缩模型)
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(
"https://www.gstatic.com/draco/versioned/decoders/1.5.6/"
);
dracoLoader.setDecoderConfig({ type: "wasm" });
loader.setDRACOLoader(dracoLoader);

// 开始加载
isLoading.value = true;

loader.load(
url,
// 加载成功回调
(gltf: any) => {
// 移除旧模型(如果存在)
if (loadedModel) {
scene.remove(loadedModel);
}
//添加到场景
const model = gltf.scene;
loadedModel = model;
scene.add(model);
// 自动调整相机位置和视角
fitCameraToObject(camera, model);
},
// 加载进度回调
(xhr: any) => { ...},
// 加载失败回调
(error: any) => { ... }
);
}

/**
* 截图当前渲染画面
* @returns PNG格式数据URL字符串
*/
const takeScreenshot = (): string => {
if (!renderer) return "";
console.log("执行截图函数");
const dataURL = renderer.domElement.toDataURL("image/png");
return dataURL;
};


/**
* 渲染循环(每帧执行)
* 更新相机位置并渲染场景
*/
function animate() {
animationFrameId = requestAnimationFrame(animate);
updateCameraPosition(); // 更新相机位置
renderer.render(scene, camera); // 渲染场景
}

// 其他代码
...

onMounted(() =>
initScene();
if (props.modelUrl && props.modelUrl.trim()) {
loadModel(props.modelUrl);
}
// 启动渲染循环
animate();
// 添加全局事件监听
...
});
</script>

解决方式

关于渲染问题通过查阅很快就找到了问题,因为截图执行的时候可能由于在未渲染的那一帧或调度等因素导致截图前当前帧没有进行渲染,只需要在截图的前强制再渲染一帧即可。

1
2
3
4
5
6
7
8
9
const takeScreenshot = (): string => {
if (!renderer) return "";
console.log("执行截图函数");
//获取截图数据URL
// 强制渲染当前帧以确保截图包含最新画面
renderer.render(scene, camera);
const dataURL = renderer.domElement.toDataURL("image/png");
return dataURL;
};

不过关于光照问题尝试多添加光源仍然不能很好的解决。

故尝试直接在渲染的时候直接把模型的轮廓渲染出来,但是效果依然不好,Three.js没办法做到内角的线条渲染,所以还需要能够透视。

不过有的时候其实并不需要线条,所以给出一个开关来控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/** 是否开启轮廓线 */
const enableOutline = ref(false);
/** 是否开启穿透 */
const disableDepthTest = ref(false);

/**
* 监听轮廓以及穿透开关变化
*/
watch([enableOutline, disableDepthTest], () => {
loadedModel.traverse((child: THREE.Object3D) => {
if ((child as THREE.Mesh).isMesh) {
// 阈值越小,检测的边越多。默认是1度,这里改为0.1度
const edge = new THREE.EdgesGeometry(
(child as THREE.Mesh).geometry,
// 0.1 // 降低阈值以检测内拐角
);
const lineMaterial = new THREE.LineBasicMaterial({
color: 0x000000,
linewidth: 2,
depthWrite: false,
depthTest: !disableDepthTest.value, // 需要对这个取反
});
const line = new THREE.LineSegments(edge, lineMaterial);
line.renderOrder = 1; // 确保线条在网格之后渲染
child.add(line);
}
});
});

但是发现又有了一个新的问题,这段代码没有办法发挥该有的作用,线一打开就没办法关闭了,而关闭又需要再次获取到轮廓线对象,又需要额外的管理line,也需要在到 watch 中再进行判断,并且这样又需要再频繁创建和销毁对象会有额外的不必要开销。

解决轮廓线控制

后面发现物体的对象其实是可以控制可见性的,那我们只需要一开始就创建,后续只需要控制可见性就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
/**
* 监听轮廓以及穿透开关变化
*/
watch([enableOutline, disableDepthTest], () => {
if (loadedModel && isModelLoaded.value) {
loadedModel.traverse((child: THREE.Object3D) => {
if ((child as THREE.Mesh).isMesh) {
// 直接遍历模型的内部对象找到轮廓线对象并更新其可见性和深度测试属性
const line = child.children.find(
(c) => (c as THREE.LineSegments).isLineSegments
) as THREE.LineSegments;
// 更新轮廓线的可见性和深度测试属性
if (line) {
line.visible = enableOutline.value;
(line.material as THREE.LineBasicMaterial).depthTest =
!disableDepthTest.value;
}
}
});
}
});

/**
* 加载3D模型文件
* @param url - 模型文件URL(.glb/.gltf格式)
*/
function loadModel(url: string) {
// URL有效性检查
if (!url || !url.trim()) {
console.warn("模型URL为空,跳过加载");
isLoading.value = false;
return;
}

const loader = new GLTFLoader();

// 配置Draco压缩解码器(用于加载压缩模型)
....

// 开始加载
isLoading.value = true;

loader.load(
url,
// 加载成功回调
(gltf: any) => {
// 移除旧模型(如果存在)
......
// 始终创建轮廓线,通过 visible 属性控制显示/隐藏
model.traverse((child: THREE.Object3D) => {
if ((child as THREE.Mesh).isMesh) {
// 阈值越小,检测的边越多。默认是1度,这里改为0.1度
const edge = new THREE.EdgesGeometry(
(child as THREE.Mesh).geometry,
// 0.1 // 降低阈值以检测内拐角
);
const lineMaterial = new THREE.LineBasicMaterial({
color: 0x000000,
linewidth: 2,
depthWrite: false,
depthTest: !disableDepthTest.value,
});
const line = new THREE.LineSegments(edge, lineMaterial);
line.renderOrder = 1; // 确保线条在网格之后渲染
line.visible = enableOutline.value; // 根据开关状态设置初始可见性
child.add(line);
}
});

// 自动调整相机位置和视角
fitCameraToObject(camera, model);
},
// 加载进度回调
(xhr: any) => {...},
// 加载失败回调
(error: any) => {...}
);
}