Files
CloneTest/index.html
T
2026-08-14 10:35:08 +08:00

212 lines
9.2 KiB
HTML

<!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>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #000;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
}
</style>
<!-- 规范的本地 Import Map,彻底切断对外部 CDN 的依赖 -->
<script type="importmap">
{
"imports": {
"three": "./libs/three.module.js",
"@mkkellogg/gaussian-splats-3d": "./libs/gaussian-splats-3d.module.js"
}
}
</script>
</head>
<body>
<script type="module">
import * as THREE from 'three';
import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d';
import ProgressText from './libs/ProgressText.js';
import DragFPSControls from './libs/DragFPSControls.js'; // 1. 引入我们的定制控制器
import SettingsPanel from './libs/SettingsPanel.js'; // <--- 新增引入这一行
// --- 核心调度逻辑:精准识别移动端触屏设备 ---
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || (navigator.maxTouchPoints > 0);
const params = new URLSearchParams(window.location.search);
const modelName = params.get('m');
if (!modelName) {
document.body.innerHTML = '<h2 style="color:white; text-align:center; font-family:sans-serif; margin-top:20%;">缺少模型参数</h2>';
throw new Error("缺少模型参数");
}
// ==========================================
// 👑 架构升级:真正的工业级格式嗅探 (网络探针)
// 彻底告别报错,全自动精准识别服务器文件!
// ==========================================
let modelUrl = '';
if (modelName.toLowerCase().endsWith('.ply') || modelName.toLowerCase().endsWith('.ksplat')) {
// 场景 A:如果你在网址明确写了后缀,直接使用,不废话。
modelUrl = '/models/' + modelName;
} else {
// 场景 B:你只传了名字,发射探针去服务器查岗!
console.log(`🔍 正在自动嗅探 [ ${modelName} ] 的真实文件格式...`);
try {
// 发送轻量级 HEAD 请求,问服务器:这个名字的 .ksplat 存在吗?
const probeResponse = await fetch('/models/' + modelName + '.ksplat', { method: 'HEAD' });
if (probeResponse.ok) {
modelUrl = '/models/' + modelName + '.ksplat';
console.log('✅ 嗅探成功:锁定为 .ksplat 格式');
} else {
modelUrl = '/models/' + modelName + '.ply';
console.log('✅ 嗅探完成:未发现 ksplat,回退使用 .ply 格式');
}
} catch (error) {
// 万一网络波动,兜底给最原始的 ply
modelUrl = '/models/' + modelName + '.ply';
}
}
// 你的完美坐标系配置
const cameraEyeHeight = 1.6;
// ==========================================
// 【核心修改】:工业级优雅降级 —— 能力检测
// 绝不猜测环境,直接测试系统底层是否支持共享内存 API
// ==========================================
const hasSharedMemory = typeof SharedArrayBuffer !== 'undefined';
// ==========================================
// 👑 架构升级:工业级设备嗅探器
// 完美区分:普通手机 vs 电脑 vs 真正的 VR 头显
// ==========================================
const isVRHeadset = /Pico|Quest|Oculus|VR/i.test(navigator.userAgent);
const isPureMobile = isMobile && !isVRHeadset; // 只有纯手机才是 true
const viewer = new GaussianSplats3D.Viewer({
'cameraUp': [0, 1, 0], // 保持你截图里的设置,维持正确的重力方向
'initialCameraPosition': [0, cameraEyeHeight, 3],
'initialCameraLookAt': [0, cameraEyeHeight, -2],
'sharedMemoryForWorkers': hasSharedMemory,
// 【核心修复1】:只有纯手机,才开启手指触控。PC 和 VR 坚决不用。
'useBuiltInControls': isPureMobile,
// 【核心修复2】:如果是纯手机,彻底阉割 VR 模块,防止陀螺仪抢夺手指触控权!
'webXRMode': isPureMobile ? undefined : GaussianSplats3D.WebXRMode.VR
});
window.history.replaceState(null, '', '?m=' + modelName);
// 3. 如果是 PC 端,挂载并启动我们自己的控制器
let customControls = null;
if (!isMobile) {
customControls = new DragFPSControls(viewer.camera, document.body);
customControls.connect();
// 2. 实例化 UI 设置面板,并建立单向数据流通信
new SettingsPanel(customControls.moveSpeed, (newSpeed) => {
// 当 UI 面板发生拖拽时,无缝将新参数注入到底层控制器中
customControls.setMoveSpeed(newSpeed);
});
}
const progressUI = new ProgressText();
viewer.addSplatScene(modelUrl, {
'progressiveLoad': true,
// 【新增】:将模型沿 Z 轴翻转 180 度 (四元数写法)
// 如果你发现翻转后上下对了,但是“左右镜像”反了,请把这行改成 'rotation': [1, 0, 0, 0] (沿 X 轴翻转)
'rotation': [0, 0, 1, 0],
// 【新增】:模型翻转后被甩到了空中,我们通过位置偏移把它按回地面!
// 数组代表 [X左右偏移, Y上下推拉, Z前后移动]
'position': [0.5, 0, 0],
'onProgress': (percent) => {
progressUI.update(percent);
if (percent >= 99.8) {
progressUI.hide();
}
}
})
.then(() => {
viewer.start();
// 准备一个空的 VR 座舱静默待命,平时绝对不干扰原系统
const xrRig = new THREE.Group();
xrRig.name = "PlayerXRRig";
// ==========================================
// 👇 XR 生命周期:动态挂载与拆卸 👇
// ==========================================
if (viewer.renderer && viewer.renderer.xr) {
// 监听 1:进入 VR 沉浸模式
viewer.renderer.xr.addEventListener('sessionstart', () => {
console.log("🥽 进入 VR:动态挂载物理座舱,接管相机!");
let targetScene = null;
for (const key in viewer) {
if (viewer[key] instanceof THREE.Scene) {
targetScene = viewer[key];
break;
}
}
if (targetScene) {
targetScene.add(xrRig);
xrRig.add(viewer.camera);
// 这里的偏移量保持你之前调试好的数值 (比如 0, -3.2, 0)
xrRig.position.set(0, 0, 1);
}
if (customControls) customControls.enabled = false;
});
// 监听 2:退出 VR 模式,回到网页
viewer.renderer.xr.addEventListener('sessionend', () => {
console.log("🖥️ 退出 VR:拆卸座舱,相机完璧归赵。");
let targetScene = null;
for (const key in viewer) {
if (viewer[key] instanceof THREE.Scene) {
targetScene = viewer[key];
break;
}
}
if (targetScene) {
targetScene.add(viewer.camera); // 相机还给原生场景
}
if (customControls) {
customControls.enabled = true;
// 重置水平滚转角,防止画面歪斜
const targetQuaternion = viewer.camera.quaternion.clone();
targetQuaternion.setFromAxisAngle(viewer.camera.up, 0);
customControls.targetQuaternion.copy(targetQuaternion);
}
});
}
// ==========================================
// 👆 XR 生命周期结束 👆
// ==========================================
})
.catch((e) => {
console.error("加载失败:", e);
document.body.innerHTML = `<h2 style="color:#ff6b6b; text-align:center; font-family:sans-serif; margin-top:20%;">模型加载失败</h2>`;
});
</script>
</body>
</html>