Commit afc33b83 by xhw

loadNewModel

parent 9dff46d5
<!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>
<title>3D高斯泼溅 - SparkSplatViewer</title>
<style>
* { margin: 0; padding: 0; }
body { overflow: hidden; background: #1a1a2e; font-family: 'Segoe UI', Arial, sans-serif; }
canvas { display: block; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
overflow: hidden;
background: #1a1a2e;
font-family: 'Segoe UI', Arial, sans-serif;
width: 100vw;
height: 100vh;
}
#viewer-container {
width: 100%;
height: 100%;
}
</style>
<base target="_blank">
<base target="_blank">
</head>
<body>
<script type="module">
import * as THREE from "./js/three.module.js";
import { SparkRenderer, SplatMesh } from "./js/spark.module.js";
import { OrbitControls } from "./js/OrbitControls.js";
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 2. 场景 / 相机 / 渲染器
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
let zoomAnimId = null;
// let autoCropEnabled = false;
const aspect = window.innerWidth / window.innerHeight;
// ── 透视相机 ──
const perspCamera = new THREE.PerspectiveCamera(20, aspect, 0.001, 10000);
// ★ 修复:初始位置设为 Z 轴方向,与模型坐标系一致
perspCamera.position.set(0, 0, 5);
let camera = perspCamera;
const renderer = new THREE.WebGLRenderer({
antialias: false,
alpha: false // ★ 修复3:关闭 alpha,避免背景混合产生边缘杂色
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
document.body.appendChild(renderer.domElement);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 3. 轨道控制器 —— 核心修复区域
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.maxDistance = 100;
controls.minDistance = 0.01;
// 限制转动范围:上下左右各10度
controls.minPolarAngle = Math.PI / 2 - Math.PI / 6;
controls.maxPolarAngle = Math.PI / 2 + Math.PI / 6;
controls.minAzimuthAngle = -Math.PI / 10;
controls.maxAzimuthAngle = Math.PI / 10;
controls.target.set(0, 0, 0);
// ★ 修复:设置正确的"上方向",确保旋转行为符合直觉
controls.object.up.set(0, 0, 0); // Y 轴向上(标准设置)
// 取消鼠标右键拖动(平移)
controls.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: null
};
<div id="viewer-container"></div>
controls.update();
// 保存初始视角
const HOME_POS = perspCamera.position.clone();
const HOME_TARGET = controls.target.clone();
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 4. Spark 渲染器
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const spark = new SparkRenderer({
renderer,
maxStdDev: Math.sqrt(5), // 限制高斯核范围,提升性能
sortRadial: true, // 径向排序,快速旋转时避免黑边(默认就是 true)
enableLod: true, // 启用 LoD(大场景必需)
lodSplatScale: 1.0 // 细节倍率
});
scene.add(spark);
const splatMesh = new SplatMesh({
url: './Advance.spz',
<script type="module">
import { SparkSplatViewer } from "./SparkSplatViewer.js";
// 初始化组件
const container = document.getElementById("viewer-container");
const viewer = new SparkSplatViewer(container, {
// 场景配置
backgroundColor: 0x1a1a2e,
// 相机配置
fov: 45,
fovMobile: 20,
initialPosition: { x: 0, y: 0, z: 5 },
// 控制器配置
enablePan: false,
minPolarAngle: Math.PI / 2 - Math.PI / 6,
maxPolarAngle: Math.PI / 2 + Math.PI / 6,
minAzimuthAngle: -Math.PI / 10,
maxAzimuthAngle: Math.PI / 10,
// 自动旋转配置(新增)
autoRotate: true, // 开启自动旋转
autoRotateSpeed: 0.1, // 速度(推荐 0.2~0.5)
autoRotateIdleDelay: 2000, // 空闲多久后恢复(ms)
// 模型配置
defaultModelUrl: "./Advance.spz",
modelRotationX: -Math.PI,
// 缩放配置
desktopZoom: 2.5,
mobileZoom: 0.55,
tabletZoom: 1.0,
// 回调
onLoad: (mesh) => {
console.log("模型加载完成");
fitCameraToModel();
const state = getDeviceType();
state === "desktop" ? animateZoomTo(2.2, 500) : animateZoomTo(0.55, 500);
// 加载完成后,把相机从当前位置往模型方向(-Z)挪
controls.update();
console.log("默认模型加载完成", mesh);
},
onError: (err) => {
console.error("模型加载失败", err);
},
onProgress: (event) => {
if (event.lengthComputable) {
const pct = ((event.loaded / event.total) * 100).toFixed(1);
console.log(`加载进度: ${pct}%`);
}
}
});
scene.add(splatMesh);
splatMesh.rotation.x = -Math.PI;
function getModelBounds() {
if (!splatMesh) return null;
const box = new THREE.Box3();
splatMesh.traverse((child) => {
if (child.isMesh && child.geometry) {
box.expandByObject(child);
} else if (child.isPoints && child.geometry) {
box.expandByObject(child);
}
// 暴露到全局,方便调试和外部调用
window.viewer = viewer;
// ═══════════════════════════════════════════════════
// 加载新模型的方法(供外部调用)
// ═══════════════════════════════════════════════════
/**
* 加载新模型并替换当前模型
* @param {string} url - 模型文件路径 (.spz, .ply, .splat, .ksplat, .sog, .rad 等)
* @param {Object} options - 可选配置
*/
window.loadNewModel = (url, options = {}) => {
viewer.loadModel(url, {
autoFit: options.autoFit !== false, // 默认自动适配相机
zoomDuration: options.zoomDuration ?? 500, // 缩放动画时长
desktopZoom: options.desktopZoom ?? 2.2, // 桌面端缩放
mobileZoom: options.mobileZoom ?? 0.55, // 移动端缩放
tabletZoom: options.tabletZoom ?? 1.0, // 平板端缩放
onComplete: options.onComplete, // 加载完成回调
onError: options.onError, // 加载失败回调
onProgress: options.onProgress, // 进度回调
});
if (box.isEmpty()) {
box.setFromObject(splatMesh);
}
return box;
}
function fitPerspectiveToModel() {
const box = getModelBounds();
console.log("模型包围盒:", box);
if (!box || box.isEmpty()) {
console.warn("无法计算包围盒,使用默认范围");
const size = 0.3;
const dist = size / (2 * Math.tan((perspCamera.fov * Math.PI) / 360));
perspCamera.position.set(0, 0, dist * 3);
controls.target.set(0, 0, 0);
controls.update();
return;
}
const center = new THREE.Vector3();
box.getCenter(center);
const size = new THREE.Vector3();
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
controls.target.copy(center);
const fovRad = (perspCamera.fov * Math.PI) / 180;
const dist = maxDim / (2 * Math.tan(fovRad / 2)) * 0.25;
// ★ 修复:从 Z 轴方向观察(标准前视图)
// 保持相机在 Z 轴方向,Y 轴向上
const newPos = center.clone();
newPos.z += Math.max(dist, 0.1); // 沿 +Z 方向后退
// 也可以根据需要从其他角度观察:
// newPos.y += dist; // 从上方俯视
// newPos.x += dist; // 从侧面观察
perspCamera.position.copy(newPos);
// ★ 修复:确保相机看向模型中心
perspCamera.lookAt(center);
controls.update();
controls.maxDistance = dist * 3;
controls.minDistance = maxDim * 0.1;
HOME_POS.copy(perspCamera.position);
HOME_TARGET.copy(controls.target);
}
function fitCameraToModel() {
fitPerspectiveToModel();
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 7. 渲染循环
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
renderer.setAnimationLoop(() => {
controls.update();
renderer.render(scene, camera);
});
// 暴露到全局供调试
window.__scene = scene;
window.__camera = camera;
window.__controls = controls;
window.__splat = splatMesh;
// 平滑过渡 zoom
function animateZoomTo(targetZoom, duration = 500) {
if (zoomAnimId) cancelAnimationFrame(zoomAnimId);
const startZoom = camera.zoom;
const startTime = performance.now();
function step(time) {
const t = Math.min((time - startTime) / duration, 1);
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
camera.zoom = startZoom + (targetZoom - startZoom) * ease;
camera.updateProjectionMatrix();
if (t < 1) {
zoomAnimId = requestAnimationFrame(step);
} else {
zoomAnimId = null;
updateInfo();
}
}
zoomAnimId = requestAnimationFrame(step);
}
function updateInfo() {
const p = camera.position;
const t = controls.target;
const r = camera.rotation;
console.log(`相机位置: (${p.x.toFixed(2)}, ${p.y.toFixed(2)}, ${p.z.toFixed(2)})`);
console.log(`旋转中心: (${t.x.toFixed(2)}, ${t.y.toFixed(2)}, ${t.z.toFixed(2)})`);
}
};
function getDeviceType() {
const ua = navigator.userAgent;
const isTablet = /iPad|Android(?!.*Mobile)/i.test(ua) ||
(/Android/i.test(ua) && window.innerWidth > 768);
// 使用示例:
// loadNewModel("./new-model.spz");
// loadNewModel("./new-model.spz", { autoFit: true, desktopZoom: 3.0 });
if (/Mobi|Android|iPhone/i.test(ua) && !isTablet) return 'mobile';
if (isTablet) return 'tablet';
return 'desktop';
}
// 其他可用方法:
// viewer.resetCamera(); // 重置相机
// viewer.fitCameraToModel(); // 适配相机到模型
// viewer.getCameraInfo(); // 获取相机信息
// viewer.animateZoomTo(2.0, 800); // 平滑缩放到指定值
// viewer.destroy(); // 销毁组件
window.addEventListener("resize", () => {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});
// 获取模型包围盒
// const bounds = viewer.getModelBounds();
</script>
</body>
</html>
\ No newline at end of file
......@@ -91,6 +91,34 @@
onComplete: options.onComplete, // 加载完成回调
onError: options.onError, // 加载失败回调
onProgress: options.onProgress, // 进度回调
},()=>{});
};
window.loadNewModel = (url, options = {}, callback = null) => {
// 简写形式: loadNewModel(url, (mesh) => { ... })
if (typeof options === "function") {
callback = options;
options = {};
}
return new Promise((resolve, reject) => {
viewer.loadModel(url, {
autoFit: options.autoFit !== false,
zoomDuration: options.zoomDuration ?? 500,
desktopZoom: options.desktopZoom ?? 2.2,
mobileZoom: options.mobileZoom ?? 0.55,
tabletZoom: options.tabletZoom ?? 1.0,
onProgress: options.onProgress,
onComplete: (mesh) => {
if (options.onComplete) options.onComplete(mesh);
if (callback) callback(mesh);
resolve(mesh);
},
onError: (err) => {
if (options.onError) options.onError(err);
reject(err);
},
});
});
};
......
<!DOCTYPE html>
<html lang="zh-CN">
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3D高斯泼溅 - 视角探索</title>
<style>
* {
margin: 0;
padding: 0;
}
body {
overflow: hidden;
background: #1a1a2e;
font-family: 'Segoe UI', Arial, sans-serif;
}
canvas {
display: block;
}
/* ── 顶部提示栏 ── */
.hint {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.7);
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
padding: 8px 20px;
border-radius: 30px;
font-size: 14px;
letter-spacing: 0.3px;
border: 1px solid rgba(255, 255, 255, 0.08);
user-select: none;
pointer-events: none;
white-space: nowrap;
z-index: 10;
}
.hint strong {
color: #ffd700;
}
/* ── 控制面板 ── */
.panel {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(12px);
padding: 12px 20px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
z-index: -10;
max-width: 90vw;
}
.panel button {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #ddd;
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
letter-spacing: 0.3px;
white-space: nowrap;
}
.panel button:hover {
background: rgba(255, 215, 0, 0.2);
border-color: #ffd700;
color: #fff;
transform: scale(1.04);
}
.panel button.active {
background: rgba(255, 215, 0, 0.25);
border-color: #ffd700;
color: #ffd700;
}
.panel .sep {
width: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 4px 6px;
}
.panel .label {
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
display: flex;
align-items: center;
margin-right: 4px;
}
/* ── 信息输出 ── */
.info {
position: fixed;
bottom: 100px;
right: 24px;
color: rgba(255, 255, 255, 0.35);
font-size: 12px;
font-family: 'Courier New', monospace;
text-align: right;
line-height: 1.6;
background: rgba(0, 0, 0, 0.3);
padding: 10px 14px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.04);
z-index: -10;
pointer-events: none;
user-select: none;
max-width: 300px;
word-break: break-all;
}
.info .highlight {
color: #ffd700;
}
.info .rot-row {
margin-top: 2px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 4px;
}
@media (max-width: 700px) {
.panel {
padding: 10px 14px;
gap: 6px;
bottom: 16px;
}
.panel button {
font-size: 12px;
padding: 4px 12px;
}
.hint {
font-size: 12px;
padding: 4px 14px;
top: 10px;
white-space: normal;
}
.info {
display: none;
}
}
</style>
<meta charset="utf-8">
<style>body { margin: 0; overflow: hidden; }</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/",
"@sparkjsdev/spark": "https://sparkjs.dev/releases/spark/2.1.0/spark.module.js"
}
}
{ "imports": { "playcanvas": "https://cdn.jsdelivr.net/npm/playcanvas/+esm" } }
</script>
<base target="_blank">
</head>
<body>
<!-- 控制面板 -->
<div class="panel" id="panel">
<span class="label">📐 视角</span>
<button data-view="front">正面</button>
<button data-view="back">背面</button>
<button data-view="left">左侧</button>
<button data-view="right">右侧</button>
<button data-view="top">俯视</button>
<button data-view="bottom">仰视</button>
<button data-view="home">复位</button>
<div class="sep"></div>
<button id="btnAutoRotate">🔄 自转</button>
<button id="btnFit">📦 适配</button>
<button id="btnProjection" title="切换透视/正交投影">🔲 正交</button>
<button id="btnAxes">🎯 坐标轴</button>
<button id="btnSave">💾 保存视角</button>
<button id="btnLoad">📂 恢复视角</button>
<button id="btnProjection" title="切换透视/正交投影">🔲 正交</button>
<!-- ▼ 新增 ▼ -->
<button id="btnAutoCrop" title="自动放大裁剪,去除画面四周留白">✂️ 去留白</button>
</div>
<!-- 信息 -->
<div class="info" id="info" >
<div>相机: <span id="camPos" class="highlight"></span></div>
<div>目标: <span id="camTarget" class="highlight"></span></div>
<div>投影: <span id="camProj" class="highlight">透视</span></div>
<div class="rot-row">旋转: <span id="camRot" class="highlight"></span></div>
<div style="margin-top:4px; opacity:0.5; font-size:11px;">
💡 控制台输入 <span style="color:#ffd700;">getView()</span> 获取完整视角信息
</div>
</div>
<script type="module">
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 1. 导入
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
import * as THREE from "three";
import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { AxesHelper } from "three";
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 2. 场景 / 相机 / 渲染器
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
const aspect = window.innerWidth / window.innerHeight;
// ── 透视相机 ──
const perspCamera = new THREE.PerspectiveCamera(50, aspect, 0.01, 1000);
perspCamera.position.set(-0.034, 0.331, -1.972);
// ── 正交相机 ──
const frustum = 5;
const orthoCamera = new THREE.OrthographicCamera(
-frustum * aspect, frustum * aspect,
frustum, -frustum,
0.01, 1000
);
orthoCamera.position.copy(perspCamera.position);
// 当前激活的相机
let useOrthographic = false;
let camera = perspCamera;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 3. 轨道控制器
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
// controls.minDistance = 0.3;
controls.maxDistance = 0.3;
// 透视模式:滚轮缩放距离限制
// controls.minDistance = 1.8;
// controls.maxDistance = 5.0;
// 正交模式:滚轮缩放倍率限制
controls.minZoom = 0.2;
controls.maxZoom = 3.0;
controls.target.set(0, 0, 0);
// 限制转动范围:上下左右各10度
controls.minPolarAngle = Math.PI / 2 - Math.PI / 18;
controls.maxPolarAngle = Math.PI / 2 + Math.PI / 18;
controls.minAzimuthAngle = -Math.PI / 18;
controls.maxAzimuthAngle = Math.PI / 18;
controls.update();
// 保存初始视角
const HOME_POS = perspCamera.position.clone();
const HOME_TARGET = controls.target.clone();
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 4. Spark 渲染器
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const spark = new SparkRenderer({ renderer });
scene.add(spark);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 5. 加载 3DGS 模型
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const SPLAT_URL = "./A11.spz";
const splatMesh = new SplatMesh({ url: SPLAT_URL });
scene.add(splatMesh);
let modelReady = false;
modelReady = true;
splatMesh.rotation.x = -Math.PI;
requestAnimationFrame(() => {
fitCameraToModel();
updateInfo();
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 6. 辅助工具
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
let axesHelper = null;
let axesVisible = false;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 7. 核心功能函数
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function getModelBounds() {
if (!splatMesh) return null;
const box = new THREE.Box3();
splatMesh.traverse((child) => {
if (child.isMesh && child.geometry) {
box.expandByObject(child);
} else if (child.isPoints && child.geometry) {
box.expandByObject(child);
}
});
if (box.isEmpty()) {
box.setFromObject(splatMesh);
}
return box;
}
// ── ★ 正交投影自动适配:根据模型包围盒精确计算 left/right/top/bottom ──
function fitOrthographicToModel() {
const box = getModelBounds();
if (!box || box.isEmpty()) {
console.warn("无法计算包围盒,使用默认正交范围");
resetOrthographicDefaults();
return;
}
const center = new THREE.Vector3();
box.getCenter(center);
// 相机坐标系的三个正交基向量
const camPos = camera.position.clone();
const forward = new THREE.Vector3().subVectors(controls.target, camPos).normalize();
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
if (right.lengthSq() < 0.0001) right.set(1, 0, 0);
const up = new THREE.Vector3().crossVectors(right, forward).normalize();
// 获取包围盒 8 个顶点,投影到相机视平面
const corners = [
new THREE.Vector3(box.min.x, box.min.y, box.min.z),
new THREE.Vector3(box.min.x, box.min.y, box.max.z),
new THREE.Vector3(box.min.x, box.max.y, box.min.z),
new THREE.Vector3(box.min.x, box.max.y, box.max.z),
new THREE.Vector3(box.max.x, box.min.y, box.min.z),
new THREE.Vector3(box.max.x, box.min.y, box.max.z),
new THREE.Vector3(box.max.x, box.max.y, box.min.z),
new THREE.Vector3(box.max.x, box.max.y, box.max.z),
];
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
let minZ = Infinity, maxZ = -Infinity;
for (const corner of corners) {
const toCorner = new THREE.Vector3().subVectors(corner, controls.target);
const projX = toCorner.dot(right);
const projY = toCorner.dot(up);
const projZ = toCorner.dot(forward);
minX = Math.min(minX, projX); maxX = Math.max(maxX, projX);
minY = Math.min(minY, projY); maxY = Math.max(maxY, projY);
minZ = Math.min(minZ, projZ); maxZ = Math.max(maxZ, projZ);
}
// 计算需要的半宽/半高(留 2% padding 避免贴边)
const padding = 1.02;
const halfW = Math.max(Math.abs(minX), Math.abs(maxX)) * padding;
const halfH = Math.max(Math.abs(minY), Math.abs(maxY)) * padding;
// 根据宽高比调整,确保模型完整显示且不留白
const viewAspect = window.innerWidth / window.innerHeight;
let finalLeft, finalRight, finalTop, finalBottom;
if (halfW / halfH > viewAspect) {
// 宽度主导:以宽度为基准
finalLeft = -halfW;
finalRight = halfW;
finalTop = halfW / viewAspect;
finalBottom = -halfW / viewAspect;
} else {
// 高度主导:以高度为基准
finalLeft = -halfH * viewAspect;
finalRight = halfH * viewAspect;
finalTop = halfH;
finalBottom = -halfH;
}
orthoCamera.left = finalLeft;
orthoCamera.right = finalRight;
orthoCamera.top = finalTop;
orthoCamera.bottom = finalBottom;
// 深度范围:确保模型在可视范围内
orthoCamera.near = Math.max(0.001, minZ - 1.0);
orthoCamera.far = Math.max(10.0, maxZ + 1.0);
orthoCamera.updateProjectionMatrix();
}
function resetOrthographicDefaults() {
const frustum = 5;
const a = window.innerWidth / window.innerHeight;
orthoCamera.left = -frustum * a;
orthoCamera.right = frustum * a;
orthoCamera.top = frustum;
orthoCamera.bottom = -frustum;
orthoCamera.near = 0.01;
orthoCamera.far = 1000;
orthoCamera.updateProjectionMatrix();
}
// ── 通用适配入口 ──
function fitCameraToModel() {
if (useOrthographic) {
fitOrthographicToModel();
} else {
fitPerspectiveToModel();
}
}
function fitPerspectiveToModel() {
const box = getModelBounds();
if (!box || box.isEmpty()) {
console.warn("无法计算包围盒,使用默认范围");
const size = 0.1;
const dist = size / (2 * Math.tan((perspCamera.fov * Math.PI) / 360));
perspCamera.position.set(0, 0, dist * 1.3);
controls.target.set(0, 0, 0);
controls.update();
return;
}
const center = new THREE.Vector3();
box.getCenter(center);
const size = new THREE.Vector3();
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
const fovRad = (perspCamera.fov * Math.PI) / 180;
const dist = maxDim / (2 * Math.tan(fovRad / 2)) * 1.2;
const dir = perspCamera.position.clone().sub(controls.target).normalize();
const newPos = center.clone().add(dir.multiplyScalar(Math.max(dist, 0.5)));
perspCamera.position.copy(newPos);
controls.target.copy(center);
controls.update();
}
// ── ★ 正交/透视切换 ──
function toggleProjection() {
useOrthographic = !useOrthographic;
const oldCam = camera;
camera = useOrthographic ? orthoCamera : perspCamera;
// 保持位置和朝向完全一致
camera.position.copy(oldCam.position);
camera.quaternion.copy(oldCam.quaternion);
// 更新控制器
controls.object = camera;
controls.update();
// 正交模式下自动裁剪适配
if (useOrthographic) {
fitOrthographicToModel();
}
// 更新按钮状态
const btn = document.getElementById('btnProjection');
btn.classList.toggle('active', useOrthographic);
btn.textContent = useOrthographic ? '🔲 正交' : '👁 透视';
updateInfo();
if (autoCropEnabled) performAutoCrop(); // ★ 新增
console.log(useOrthographic ? '🔲 已切换为正交投影' : '👁 已切换为透视投影');
}
function flyTo(pos, target, duration = 600) {
const startPos = camera.position.clone();
const startTarget = controls.target.clone();
const endPos = pos.clone();
const endTarget = target.clone();
const startTime = performance.now();
function animateFly(time) {
const t = Math.min((time - startTime) / duration, 1);
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
camera.position.lerpVectors(startPos, endPos, ease);
controls.target.lerpVectors(startTarget, endTarget, ease);
controls.update();
if (t < 1) {
requestAnimationFrame(animateFly);
} else {
camera.position.copy(endPos);
controls.target.copy(endTarget);
controls.update();
if (useOrthographic) fitOrthographicToModel();
updateInfo();
if (autoCropEnabled) performAutoCrop(); // ★ 新增
}
}
requestAnimationFrame(animateFly);
}
function setView(name) {
const box = getModelBounds();
const target = new THREE.Vector3(0, 0, 0);
let maxDim = 3;
if (box && !box.isEmpty()) {
box.getCenter(target);
const size = new THREE.Vector3();
box.getSize(size);
maxDim = Math.max(size.x, size.y, size.z);
}
if (useOrthographic) {
// 正交模式下:固定标准距离,靠包围盒计算投影范围
const dist = Math.max(maxDim * 1.5, 1.0);
let pos;
switch (name) {
case 'front': pos = new THREE.Vector3(0, 0, dist); break;
case 'back': pos = new THREE.Vector3(0, 0, -dist); break;
case 'left': pos = new THREE.Vector3(-dist, 0, 0); break;
case 'right': pos = new THREE.Vector3(dist, 0, 0); break;
case 'top': pos = new THREE.Vector3(0, dist, 0.01); break;
case 'bottom': pos = new THREE.Vector3(0, -dist, 0.01); break;
case 'home':
flyTo(HOME_POS, HOME_TARGET);
return;
default: return;
}
flyTo(pos, target);
} else {
// 透视模式:根据 FOV 计算距离
const fovRad = (perspCamera.fov * Math.PI) / 180;
const baseDist = maxDim / (2 * Math.tan(fovRad / 2)) * 1.3;
const d = Math.max(Math.min(baseDist, 8), 0.8);
let pos;
switch (name) {
case 'front': pos = new THREE.Vector3(0, 0, d); break;
case 'back': pos = new THREE.Vector3(0, 0, -d); break;
case 'left': pos = new THREE.Vector3(-d, 0, 0); break;
case 'right': pos = new THREE.Vector3(d, 0, 0); break;
case 'top': pos = new THREE.Vector3(0, d, 0.01); break;
case 'bottom': pos = new THREE.Vector3(0, -d, 0.01); break;
case 'home':
flyTo(HOME_POS, HOME_TARGET);
return;
default: return;
}
flyTo(pos, target);
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 7.1 ★ 更新信息
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 7.5 ★ 自动去留白(Auto-Crop)
// 原理:相机位置不动,像素探测 + 二分搜索 camera.zoom
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const PROBE_SIZE = 96; // 探测分辨率(越大越精确,越小越快)
const EDGE_RING = 2; // 检测最外圈几个像素
const ALPHA_SOLID = 40; // alpha 低于此值视为"留白"(调大可裁掉半透明毛边,如 120~180)
const MAX_ZOOM = 40; // 放大倍率上限
const KEY_R = 255, KEY_G = 0, KEY_B = 255; // 背景色键(品红,泼溅内容几乎不会撞色)
const probeRT = new THREE.WebGLRenderTarget(PROBE_SIZE, PROBE_SIZE);
const probeBuf = new Uint8Array(PROBE_SIZE * PROBE_SIZE * 4);
let autoCropEnabled = false;
let zoomAnimId = null;
// 以指定 zoom 渲染一帧到离屏小图并读回像素
function renderProbe(zoomValue) {
const oldZoom = camera.zoom;
const oldBg = scene.background;
const oldColor = new THREE.Color();
renderer.getClearColor(oldColor);
const oldAlpha = renderer.getClearAlpha();
camera.zoom = zoomValue;
camera.updateProjectionMatrix();
scene.background = null; // 不画背景色
renderer.setRenderTarget(probeRT);
renderer.setClearColor(new THREE.Color(KEY_R/255, KEY_G/255, KEY_B/255), 0);
renderer.clear(true, true, true);
renderer.render(scene, camera);
renderer.readRenderTargetPixels(probeRT, 0, 0, PROBE_SIZE, PROBE_SIZE, probeBuf);
renderer.setRenderTarget(null);
// 还原现场
scene.background = oldBg;
renderer.setClearColor(oldColor, oldAlpha);
camera.zoom = oldZoom;
camera.updateProjectionMatrix();
return probeBuf;
}
// 判断某个像素是否为"背景/留白"
// 依据:alpha 很低,或颜色非常接近我们设定的 key 色
function isBackgroundPixel(buf, idx) {
const a = buf[idx + 3];
if (a < ALPHA_SOLID) return true;
const r = buf[idx], g = buf[idx + 1], b = buf[idx + 2];
const dr = r - KEY_R, dg = g - KEY_G, db = b - KEY_B;
const distSq = dr * dr + dg * dg + db * db;
return distSq < 900; // 颜色距离阈值,防止 key 色泄露判定失误
}
// 检测某个 zoom 值下,画面四周是否还存在留白
function hasEdgeBackground(zoomValue) {
const buf = renderProbe(zoomValue);
const w = PROBE_SIZE, h = PROBE_SIZE;
const checkPixel = (x, y) => {
const idx = (y * w + x) * 4;
return isBackgroundPixel(buf, idx);
};
// 扫描最外圈 EDGE_RING 层像素
for (let ring = 0; ring < EDGE_RING; ring++) {
for (let x = ring; x < w - ring; x++) {
if (checkPixel(x, ring)) return true; // 上边
if (checkPixel(x, h - 1 - ring)) return true; // 下边
}
for (let y = ring; y < h - ring; y++) {
if (checkPixel(ring, y)) return true; // 左边
if (checkPixel(w - 1 - ring, y)) return true; // 右边
}
}
return false;
}
// 二分搜索:找到"刚好没有留白"的最小 zoom
function findMinZoomNoBackground() {
let lo = 1.0;
let hi = MAX_ZOOM;
// 先确认上限是否足够(极端情况:模型很小、视角刁钻)
if (hasEdgeBackground(hi)) {
console.warn('⚠️ 已达最大放大倍率仍有留白,可能视角朝向了模型外部空白区域');
return hi;
}
// 若初始 zoom=1 就已经没有留白,直接返回
if (!hasEdgeBackground(lo)) {
return lo;
}
const ITER = 14; // 二分次数,14 次已足够精细(1/2^14)
for (let i = 0; i < ITER; i++) {
const mid = (lo + hi) / 2;
if (hasEdgeBackground(mid)) {
lo = mid; // 还有留白,需要继续放大
} else {
hi = mid; // 没留白了,尝试缩小一点看临界
}
}
return hi * 1.01; // 留一点点余量,避免临界抖动导致又露出留白
}
// 平滑过渡到目标 zoom
function animateZoomTo(targetZoom, duration = 500) {
if (zoomAnimId) cancelAnimationFrame(zoomAnimId);
const startZoom = camera.zoom;
const startTime = performance.now();
function step(time) {
const t = Math.min((time - startTime) / duration, 1);
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
camera.zoom = startZoom + (targetZoom - startZoom) * ease;
camera.updateProjectionMatrix();
if (t < 1) {
zoomAnimId = requestAnimationFrame(step);
} else {
zoomAnimId = null;
updateInfo();
}
}
zoomAnimId = requestAnimationFrame(step);
}
// 对外入口:执行一次自动去留白
function performAutoCrop() {
if (!splatMesh) return;
const targetZoom = findMinZoomNoBackground();
console.log(targetZoom)
animateZoomTo(targetZoom);
console.log(`✂️ 自动去留白完成,zoom = ${targetZoom.toFixed(3)}`);
}
// 开关:开启后,相机每次停止拖动会自动重新计算裁剪
window.toggleAutoCrop = () => {
autoCropEnabled = !autoCropEnabled;
const btn = document.getElementById('btnAutoCrop');
btn.classList.toggle('active', autoCropEnabled);
if (autoCropEnabled) {
performAutoCrop();
console.log('✂️ 自动去留白:已开启(拖动结束会自动重新裁剪)');
} else {
// 关闭时恢复 zoom = 1
animateZoomTo(1.0);
console.log('✂️ 自动去留白:已关闭');
}
};
window.performAutoCrop = performAutoCrop;
function updateInfo() {
const p = camera.position;
const t = controls.target;
document.getElementById('camPos').textContent =
`${p.x.toFixed(3)}, ${p.y.toFixed(3)}, ${p.z.toFixed(3)}`;
document.getElementById('camTarget').textContent =
`${t.x.toFixed(3)}, ${t.y.toFixed(3)}, ${t.z.toFixed(3)}`;
document.getElementById('camProj').textContent =
useOrthographic ? '正交 (Orthographic)' : '透视 (Perspective)';
const r = camera.rotation;
const degX = (r.x * 180 / Math.PI);
const degY = (r.y * 180 / Math.PI);
const degZ = (r.z * 180 / Math.PI);
document.getElementById('camRot').textContent =
`${degX.toFixed(1)}°, ${degY.toFixed(1)}°, ${degZ.toFixed(1)}°`;
}
setInterval(updateInfo, 300);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 8. ★ 全局辅助函数
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
window.getView = () => {
const p = camera.position;
const t = controls.target;
const r = camera.rotation;
const q = camera.quaternion;
const dir = new THREE.Vector3().copy(t).sub(p).normalize();
let azimuth = null;
let polar = null;
try {
azimuth = controls.getAzimuthalAngle ? controls.getAzimuthalAngle() : null;
polar = controls.getPolarAngle ? controls.getPolarAngle() : null;
} catch (_) { }
return {
projection: useOrthographic ? 'orthographic' : 'perspective',
position: [p.x, p.y, p.z],
target: [t.x, t.y, t.z],
direction: [dir.x, dir.y, dir.z],
euler: {
rad: { x: r.x, y: r.y, z: r.z },
deg: {
x: r.x * 180 / Math.PI,
y: r.y * 180 / Math.PI,
z: r.z * 180 / Math.PI
}
},
quaternion: [q.x, q.y, q.z, q.w],
orbit: {
azimuth: azimuth !== null ? azimuth * 180 / Math.PI : null,
polar: polar !== null ? polar * 180 / Math.PI : null
},
toString() {
const lines = [
`🔲 投影: ${useOrthographic ? '正交 (Orthographic)' : '透视 (Perspective)'}`,
`📍 位置: (${p.x.toFixed(4)}, ${p.y.toFixed(4)}, ${p.z.toFixed(4)})`,
`🎯 目标: (${t.x.toFixed(4)}, ${t.y.toFixed(4)}, ${t.z.toFixed(4)})`,
`🧭 方向: (${dir.x.toFixed(4)}, ${dir.y.toFixed(4)}, ${dir.z.toFixed(4)})`,
`🔄 旋转 (deg): X=${(r.x * 180 / Math.PI).toFixed(2)}°, Y=${(r.y * 180 / Math.PI).toFixed(2)}°, Z=${(r.z * 180 / Math.PI).toFixed(2)}°`,
`📐 四元数: (${q.x.toFixed(4)}, ${q.y.toFixed(4)}, ${q.z.toFixed(4)}, ${q.w.toFixed(4)})`,
];
if (azimuth !== null && polar !== null) {
lines.push(`🌐 方位角: ${(azimuth * 180 / Math.PI).toFixed(2)}°, 极角: ${(polar * 180 / Math.PI).toFixed(2)}°`);
}
return lines.join('\n');
}
};
};
window.getCameraRotation = () => {
const r = camera.rotation;
const q = camera.quaternion;
const deg = {
x: r.x * 180 / Math.PI,
y: r.y * 180 / Math.PI,
z: r.z * 180 / Math.PI
};
return {
euler: { rad: { x: r.x, y: r.y, z: r.z }, deg },
quaternion: [q.x, q.y, q.z, q.w],
toString() {
return `欧拉角 (deg): X=${deg.x.toFixed(2)}°, Y=${deg.y.toFixed(2)}°, Z=${deg.z.toFixed(2)}°\n` +
`欧拉角 (rad): X=${r.x.toFixed(4)}, Y=${r.y.toFixed(4)}, Z=${r.z.toFixed(4)}\n` +
`四元数: (${q.x.toFixed(4)}, ${q.y.toFixed(4)}, ${q.z.toFixed(4)}, ${q.w.toFixed(4)})`;
}
};
};
window.saveView = () => {
const data = {
pos: camera.position.toArray(),
target: controls.target.toArray(),
orthographic: useOrthographic
};
localStorage.setItem('savedView', JSON.stringify(data));
console.log('✅ 视角已保存到 localStorage');
};
window.loadView = () => {
const raw = localStorage.getItem('savedView');
if (!raw) { console.warn('⚠️ 没有保存的视角'); return; }
try {
const data = JSON.parse(raw);
const pos = new THREE.Vector3(...data.pos);
const target = new THREE.Vector3(...data.target);
// 如果保存的是正交视角,先切换
if (data.orthographic && !useOrthographic) {
toggleProjection();
} else if (!data.orthographic && useOrthographic) {
toggleProjection();
}
flyTo(pos, target);
console.log('✅ 视角已恢复');
} catch (e) {
console.warn('⚠️ 恢复视角失败', e);
}
};
let autoRotate = false;
window.toggleAutoRotate = () => {
autoRotate = !autoRotate;
document.getElementById('btnAutoRotate').classList.toggle('active', autoRotate);
console.log(autoRotate ? '🔄 自动旋转 开启' : '⏸ 自动旋转 关闭');
};
window.toggleAxes = () => {
if (!axesHelper) {
axesHelper = new AxesHelper(1.2);
scene.add(axesHelper);
}
axesVisible = !axesVisible;
axesHelper.visible = axesVisible;
document.getElementById('btnAxes').classList.toggle('active', axesVisible);
console.log(axesVisible ? '🎯 坐标轴 显示' : '🎯 坐标轴 隐藏');
};
window.toggleProjection = toggleProjection;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 9. 绑定 UI 事件
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
document.querySelectorAll('[data-view]').forEach((btn) => {
btn.addEventListener('click', () => {
const view = btn.dataset.view;
setView(view);
if (autoRotate) {
autoRotate = false;
document.getElementById('btnAutoRotate').classList.remove('active');
}
});
});
document.getElementById('btnAutoCrop').addEventListener('click', window.toggleAutoCrop);
document.getElementById('btnAutoRotate').addEventListener('click', window.toggleAutoRotate);
document.getElementById('btnFit').addEventListener('click', () => {
fitCameraToModel();
if (autoCropEnabled) performAutoCrop(); // ★ 新增
if (autoRotate) {
autoRotate = false;
document.getElementById('btnAutoRotate').classList.remove('active');
}
});
document.getElementById('btnProjection').addEventListener('click', toggleProjection);
document.getElementById('btnAxes').addEventListener('click', window.toggleAxes);
document.getElementById('btnSave').addEventListener('click', () => {
window.saveView();
const el = document.getElementById('btnSave');
el.textContent = '✅ 已保存';
setTimeout(() => { el.textContent = '💾 保存视角'; }, 1200);
});
document.getElementById('btnLoad').addEventListener('click', () => {
window.loadView();
if (autoRotate) {
autoRotate = false;
document.getElementById('btnAutoRotate').classList.remove('active');
}
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 10. 窗口自适应
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
window.addEventListener('resize', () => {
const w = window.innerWidth;
const h = window.innerHeight;
const a = w / h;
if (useOrthographic) {
// 正交相机:保持投影范围比例,根据宽高比调整
const halfH = orthoCamera.top;
orthoCamera.left = -halfH * a;
orthoCamera.right = halfH * a;
orthoCamera.updateProjectionMatrix();
} else {
perspCamera.aspect = a;
perspCamera.updateProjectionMatrix();
}
renderer.setSize(w, h);
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 11. 键盘快捷键
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
document.addEventListener('keydown', (e) => {
switch (e.key.toLowerCase()) {
case '1': setView('front'); break;
case '2': setView('back'); break;
case '3': setView('left'); break;
case '4': setView('right'); break;
case '5': setView('top'); break;
case '6': setView('bottom'); break;
case '0': setView('home'); break;
case 'r': window.toggleAutoRotate(); break;
case 'f': fitCameraToModel(); break;
case 'p': toggleProjection(); break;
case 'a': window.toggleAxes(); break;
case 's': window.saveView(); break;
case 'l': window.loadView(); break;
case 'i':
console.log('📐 相机旋转角度:\n' + window.getCameraRotation().toString());
break;
case 'v':
console.log('📐 完整视角信息:\n' + window.getView().toString());
break;
default: break;
}
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 12. 渲染循环
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
renderer.setAnimationLoop(() => {
if (autoRotate && splatMesh) {
splatMesh.rotation.y += 0.006;
}
controls.update();
controls.addEventListener('end', () => {
if (!autoCropEnabled) return;
clearTimeout(cropDebounceTimer);
cropDebounceTimer = setTimeout(() => {
performAutoCrop();
}, 150); // 拖动松手后延迟一点再算,避免频繁触发
});
renderer.render(scene, camera);
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 13. 启动后自动适配
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
setTimeout(() => {
if (modelReady) {
fitCameraToModel();
} else {
splatMesh.addEventListener("load", () => {
modelReady = true;
fitCameraToModel();
});
}
animateZoomTo(2.2,0);
}, 500);
console.log('🚀 3D高斯泼溅查看器已启动');
console.log('📖 快捷键: 1-6 视角 | 0 复位 | R 自转 | F 适配 | P 切换投影 | A 坐标轴 | S 保存 | L 恢复');
console.log('📐 按 I 键查看相机旋转角度,按 V 键查看完整视角信息');
console.log('💡 在控制台输入 getView() 获取完整视角信息');
console.log('💡 在控制台输入 toggleProjection() 切换正交/透视投影');
window.__scene = scene;
window.__camera = camera;
window.__controls = controls;
window.__splat = splatMesh;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 14. 双击输出视角
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
renderer.domElement.addEventListener('dblclick', () => {
console.log('📐 当前视角:\n' + window.getView().toString());
});
console.log('✅ 双击画面可在控制台输出当前视角');
</script>
<script type="module">
import * as pc from 'playcanvas';
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);
const app = new pc.Application(canvas, {
graphicsDeviceOptions: { antialias: false }
});
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
app.start();
window.addEventListener('resize', () => app.resizeCanvas());
// 相机(这个场景的包围盒约 ±200 单位,像厘米尺度,所以放远一点)
const camera = new pc.Entity('camera');
camera.addComponent('camera', { nearClip: 1, farClip: 2000 });
camera.setPosition(150, 120, 250);
camera.lookAt(0, 0, 0);
app.root.addChild(camera);
// 关键:asset 直接指向 lod-meta.json,gsplat 组件开 unified
const asset = new pc.Asset('scene', 'gsplat', {
url: './les-tanins/lod-meta.json'
});
app.assets.add(asset);
app.assets.load(asset);
asset.ready(() => {
const splat = new pc.Entity('splat');
splat.addComponent('gsplat', { asset, unified: true }); // unified = 流式 LOD 渲染
app.root.addChild(splat);
});
</script>
</body>
</html>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment