3D模型查看器
最近做了一个3d建模,但是没有找到轻量展示3d模型的工具,写了一个html实现展示3d模型,支持GLB、OBJ、FBX和STL格式,使用Three.js实现。
设计思路:
- 使用Three.js作为核心3D渲染库
- 实现文件拖放和选择功能
- 添加模型控制面板(旋转、缩放、重置)
- 支持多种光源设置
- 添加加载状态指示器
- 响应式设计适配不同屏幕
下面是完整的实现代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D模型查看器</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/loaders/GLTFLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/loaders/OBJLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/loaders/FBXLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/loaders/STLLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/controls/OrbitControls.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
background: linear-gradient(135deg, #1a2a6c, #2c3e50);
color: #fff;
min-height: 100vh;
overflow-x: hidden;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
padding: 30px 0;
margin-bottom: 20px;
}
h1 {
font-size: 2.8rem;
margin-bottom: 10px;
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
background: linear-gradient(to right, #ff7e5f, #feb47b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-size: 1.2rem;
opacity: 0.8;
max-width: 600px;
margin: 0 auto;
line-height: 1.6;
}
.content {
display: flex;
flex-wrap: wrap;
gap: 30px;
margin-bottom: 30px;
}
.viewer-container {
flex: 1;
min-width: 300px;
height: 500px;
background: rgba(0, 0, 0, 0.2);
border-radius: 15px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
position: relative;
}
#model-viewer {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(10, 15, 30, 0.7);
}
.controls {
flex: 0 0 300px;
background: rgba(30, 40, 60, 0.7);
border-radius: 15px;
padding: 25px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
}
.section {
margin-bottom: 25px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.section-title {
font-size: 1.3rem;
margin-bottom: 15px;
color: #ff7e5f;
display: flex;
align-items: center;
}
.section-title i {
margin-right: 10px;
font-size: 1.2rem;
}
.btn-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
}
button {
padding: 10px 18px;
border: none;
border-radius: 8px;
background: linear-gradient(to right, #ff7e5f, #feb47b);
color: white;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
flex: 1;
min-width: 120px;
}
button:hover {
transform: translateY(-3px);
box-shadow: 0 5px 15px rgba(255, 126, 95, 0.4);
}
button:active {
transform: translateY(1px);
}
.file-input {
width: 100%;
padding: 15px;
border: 2px dashed rgba(255, 255, 255, 0.3);
border-radius: 10px;
text-align: center;
margin: 15px 0;
background: rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
cursor: pointer;
}
.file-input:hover {
border-color: #ff7e5f;
background: rgba(255, 126, 95, 0.1);
}
.file-input.drag-over {
border-color: #4CAF50;
background: rgba(76, 175, 80, 0.1);
}
.slider-container {
margin: 15px 0;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
input[type="range"] {
width: 100%;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
outline: none;
-webkit-appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #ff7e5f;
cursor: pointer;
}
.status {
text-align: center;
padding: 15px;
background: rgba(0, 0, 0, 0.3);
border-radius: 10px;
margin-top: 20px;
font-weight: 500;
}
.status.loading {
color: #feb47b;
}
.status.success {
color: #4CAF50;
}
.status.error {
color: #f44336;
}
.format-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-top: 30px;
}
.format-card {
background: rgba(30, 40, 60, 0.7);
border-radius: 10px;
padding: 20px;
text-align: center;
transition: transform 0.3s ease;
}
.format-card:hover {
transform: translateY(-5px);
background: rgba(40, 50, 80, 0.8);
}
.format-icon {
font-size: 2.5rem;
margin-bottom: 15px;
color: #feb47b;
}
.format-name {
font-size: 1.3rem;
margin-bottom: 10px;
color: #ff7e5f;
}
.format-desc {
font-size: 0.95rem;
opacity: 0.8;
line-height: 1.5;
}
footer {
text-align: center;
padding: 30px 0;
margin-top: 30px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.9rem;
opacity: 0.7;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 5px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #ff7e5f;
animation: spin 1s linear infinite;
display: none;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.content {
flex-direction: column;
}
.viewer-container {
height: 400px;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>3D模型查看器</h1>
<p class="subtitle">上传并查看GLB、OBJ、FBX和STL格式的3D模型。支持旋转、缩放和平移操作。</p>
</header>
<div class="content">
<div class="viewer-container">
<div id="model-viewer">
<div class="loading-spinner" id="spinner"></div>
</div>
</div>
<div class="controls">
<div class="section">
<h3 class="section-title">模型操作</h3>
<div class="btn-group">
<button id="reset-view">重置视图</button>
<button id="auto-rotate">自动旋转</button>
</div>
<div class="slider-container">
<label for="rotation-speed">旋转速度</label>
<input type="range" id="rotation-speed" min="0" max="2" step="0.1" value="0.5">
</div>
<div class="slider-container">
<label for="model-scale">模型缩放</label>
<input type="range" id="model-scale" min="0.1" max="2" step="0.1" value="1">
</div>
</div>
<div class="section">
<h3 class="section-title">光源设置</h3>
<div class="btn-group">
<button id="ambient-light">环境光</button>
<button id="directional-light">方向光</button>
</div>
<div class="slider-container">
<label for="light-intensity">光照强度</label>
<input type="range" id="light-intensity" min="0" max="2" step="0.1" value="1">
</div>
</div>
<div class="section">
<h3 class="section-title">导入模型</h3>
<div class="file-input" id="drop-zone">
拖放文件到这里或点击选择
<input type="file" id="file-input" accept=".glb,.obj,.fbx,.stl" style="display: none;">
</div>
<div class="status" id="status">等待导入模型...</div>
</div>
</div>
</div>
<div class="format-info">
<div class="format-card">
<div class="format-icon">📦</div>
<h3 class="format-name">GLB 格式</h3>
<p class="format-desc">二进制格式的glTF文件,包含3D模型、材质、动画等所有数据。</p>
</div>
<div class="format-card">
<div class="format-icon">🔷</div>
<h3 class="format-name">OBJ 格式</h3>
<p class="format-desc">简单的3D模型格式,包含几何体数据,通常需要配合MTL材质文件。</p>
</div>
<div class="format-card">
<div class="format-icon">🎭</div>
<h3 class="format-name">FBX 格式</h3>
<p class="format-desc">Autodesk开发的3D模型格式,支持动画、骨骼、材质等高级特性。</p>
</div>
<div class="format-card">
<div class="format-icon">🧊</div>
<h3 class="format-name">STL 格式</h3>
<p class="format-desc">用于3D打印的标准格式,仅包含几何表面信息,不包含颜色或材质。</p>
</div>
</div>
<footer>
<p>使用Three.js构建的3D模型查看器 | 支持GLB, OBJ, FBX, STL格式</p>
</footer>
</div>
<script>
// 初始化变量
let scene, camera, renderer, controls;
let model = null;
let autoRotate = false;
let rotationSpeed = 0.5;
let directionalLight, ambientLight;
// 初始化Three.js场景
function init() {
// 创建场景
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0f1a);
scene.fog = new THREE.Fog(0x0a0f1a, 20, 100);
// 创建相机
camera = new THREE.PerspectiveCamera(75,
document.getElementById('model-viewer').clientWidth /
document.getElementById('model-viewer').clientHeight,
0.1, 1000
);
camera.position.z = 5;
// 创建渲染器
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(
document.getElementById('model-viewer').clientWidth,
document.getElementById('model-viewer').clientHeight
);
renderer.setPixelRatio(window.devicePixelRatio);
document.getElementById('model-viewer').appendChild(renderer.domElement);
// 添加轨道控制
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
// 添加光源
ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(1, 1, 1);
scene.add(directionalLight);
// 添加辅助网格和坐标轴
const gridHelper = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
scene.add(gridHelper);
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);
// 添加窗口大小调整监听
window.addEventListener('resize', onWindowResize);
// 开始动画循环
animate();
}
// 处理窗口大小调整
function onWindowResize() {
camera.aspect = document.getElementById('model-viewer').clientWidth /
document.getElementById('model-viewer').clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(
document.getElementById('model-viewer').clientWidth,
document.getElementById('model-viewer').clientHeight
);
}
// 动画循环
function animate() {
requestAnimationFrame(animate);
if (autoRotate && model) {
model.rotation.y += rotationSpeed * 0.01;
}
controls.update();
renderer.render(scene, camera);
}
// 加载3D模型
function loadModel(file) {
showLoading(true);
const reader = new FileReader();
const extension = file.name.split('.').pop().toLowerCase();
const statusElement = document.getElementById('status');
reader.onload = function(event) {
try {
// 移除现有模型
if (model) {
scene.remove(model);
}
let loader;
const modelUrl = URL.createObjectURL(file);
switch(extension) {
case 'glb':
loader = new THREE.GLTFLoader();
loader.load(modelUrl, function(gltf) {
model = gltf.scene;
centerModel(model);
scene.add(model);
showLoading(false);
statusElement.textContent = `成功加载: ${file.name}`;
statusElement.className = 'status success';
}, undefined, function(error) {
console.error(error);
statusElement.textContent = `加载失败: ${error.message}`;
statusElement.className = 'status error';
showLoading(false);
});
break;
case 'obj':
loader = new THREE.OBJLoader();
model = loader.parse(event.target.result);
centerModel(model);
scene.add(model);
showLoading(false);
statusElement.textContent = `成功加载: ${file.name}`;
statusElement.className = 'status success';
break;
case 'fbx':
loader = new THREE.FBXLoader();
model = loader.parse(event.target.result);
centerModel(model);
scene.add(model);
showLoading(false);
statusElement.textContent = `成功加载: ${file.name}`;
statusElement.className = 'status success';
break;
case 'stl':
loader = new THREE.STLLoader();
const geometry = loader.parse(event.target.result);
const material = new THREE.MeshPhongMaterial({
color: 0xff5533,
specular: 0x111111,
shininess: 200
});
model = new THREE.Mesh(geometry, material);
centerModel(model);
scene.add(model);
showLoading(false);
statusElement.textContent = `成功加载: ${file.name}`;
statusElement.className = 'status success';
break;
default:
statusElement.textContent = '不支持的格式: ' + extension;
statusElement.className = 'status error';
showLoading(false);
break;
}
} catch (error) {
console.error(error);
statusElement.textContent = `加载错误: ${error.message}`;
statusElement.className = 'status error';
showLoading(false);
}
};
if (extension === 'obj' || extension === 'fbx' || extension === 'stl') {
reader.readAsText(file);
} else {
reader.readAsArrayBuffer(file);
}
}
// 居中模型
function centerModel(model) {
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
model.position.sub(center);
// 缩放模型到合适大小
const size = box.getSize(new THREE.Vector3()).length();
const scale = 5 / size;
model.scale.set(scale, scale, scale);
}
// 显示/隐藏加载指示器
function showLoading(show) {
const spinner = document.getElementById('spinner');
spinner.style.display = show ? 'block' : 'none';
}
// 初始化事件监听器
function initEventListeners() {
// 文件选择
const fileInput = document.getElementById('file-input');
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
loadModel(e.target.files[0]);
}
});
// 拖放事件
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
if (e.dataTransfer.files.length > 0) {
loadModel(e.dataTransfer.files[0]);
}
});
// 控制按钮
document.getElementById('reset-view').addEventListener('click', () => {
camera.position.set(0, 0, 5);
camera.lookAt(0, 0, 0);
if (model) {
model.rotation.set(0, 0, 0);
}
autoRotate = false;
document.getElementById('auto-rotate').textContent = '自动旋转';
});
document.getElementById('auto-rotate').addEventListener('click', () => {
autoRotate = !autoRotate;
this.textContent = autoRotate ? '停止旋转' : '自动旋转';
});
// 滑块控制
document.getElementById('rotation-speed').addEventListener('input', (e) => {
rotationSpeed = parseFloat(e.target.value);
});
document.getElementById('model-scale').addEventListener('input', (e) => {
if (model) {
const scale = parseFloat(e.target.value);
model.scale.set(scale, scale, scale);
}
});
document.getElementById('light-intensity').addEventListener('input', (e) => {
const intensity = parseFloat(e.target.value);
ambientLight.intensity = intensity * 0.6;
directionalLight.intensity = intensity * 0.8;
});
}
// 页面加载完成后初始化
window.onload = function() {
init();
initEventListeners();
};
</script>
</body>
</html>
功能说明
这个3D模型查看器具有以下功能:
-
模型支持:
- 支持加载和显示GLB、OBJ、FBX和STL格式的3D模型
- 每种格式都有相应的说明卡片
-
模型操作:
- 使用鼠标拖拽旋转模型
- 鼠标滚轮缩放
- 右键拖拽平移视图
- 自动旋转功能
- 模型缩放控制
-
光源控制:
- 可调整环境光和方向光强度
- 默认开启两种光源
-
导入方式:
- 点击选择文件按钮上传
- 拖放文件到指定区域
-
状态显示:
- 显示加载状态(等待、加载中、成功、错误)
- 加载动画指示器
-
其他功能:
- 重置视图按钮
- 网格和坐标轴辅助工具
- 响应式设计适配不同设备
使用此查看器时,只需将您的3D模型文件拖放到指定区域或点击选择文件按钮即可加载模型。加载后,您可以通过鼠标控制模型视图,并使用控制面板调整模型显示效果。
注意:由于浏览器安全限制,您需要从本地服务器运行此页面(如使用Live Server扩展)才能正确加载模型文件。