Commit 9dff46d5 by xhw

Initial commit

parents
File added
File added
File added
/**
* SparkSplatViewer - 3D高斯泼溅渲染器组件
* 基于 Three.js + SparkJS 封装,使用本地文件
*
* 使用方法:
* const viewer = new SparkSplatViewer(container, options);
* viewer.loadModel(url, options);
* viewer.destroy();
*/
import * as THREE from "./js/three.module.js";
import { SparkRenderer, SplatMesh } from "./js/spark.module.js";
import { OrbitControls } from "./js/OrbitControls.js";
export class SparkSplatViewer {
/**
* @param {HTMLElement} container - 渲染容器DOM元素
* @param {Object} options - 配置选项
*/
constructor(container, options = {}) {
this.container = container;
this.options = {
// 场景配置
backgroundColor: options.backgroundColor ?? 0x1a1a2e,
// 相机配置
fov: options.fov ?? 45,
fovMobile: options.fovMobile ?? 20,
near: options.near ?? 0.001,
far: options.far ?? 10000,
initialPosition: options.initialPosition ?? new THREE.Vector3(0, 0, 5),
// 控制器配置
dampingFactor: options.dampingFactor ?? 0.08,
maxDistance: options.maxDistance ?? 100,
minDistance: options.minDistance ?? 0.01,
minPolarAngle: options.minPolarAngle ?? (Math.PI / 2 - Math.PI / 6),
maxPolarAngle: options.maxPolarAngle ?? (Math.PI / 2 + Math.PI / 6),
minAzimuthAngle: options.minAzimuthAngle ?? -Math.PI / 10,
maxAzimuthAngle: options.maxAzimuthAngle ?? Math.PI / 10,
enablePan: options.enablePan ?? false,
// Spark渲染器配置
maxStdDev: options.maxStdDev ?? Math.sqrt(5),
sortRadial: options.sortRadial ?? true,
enableLod: options.enableLod ?? true,
lodSplatScale: options.lodSplatScale ?? 1.0,
// 模型配置
defaultModelUrl: options.defaultModelUrl ?? "./Advance.spz",
modelRotationX: options.modelRotationX ?? -Math.PI,
// 缩放动画配置
zoomDuration: options.zoomDuration ?? 500,
desktopZoom: options.desktopZoom ?? 2.5,
mobileZoom: options.mobileZoom ?? 0.55,
tabletZoom: options.tabletZoom ?? 1.0,
// 自动旋转配置
autoRotate: options.autoRotate ?? true,
autoRotateSpeed: options.autoRotateSpeed ?? 0.3,
autoRotateIdleDelay: options.autoRotateIdleDelay ?? 2000,
// 回调函数
onLoad: options.onLoad ?? null,
onProgress: options.onProgress ?? null,
onError: options.onError ?? null,
};
// 内部状态
this.scene = null;
this.camera = null;
this.renderer = null;
this.controls = null;
this.spark = null;
this.splatMesh = null;
this.zoomAnimId = null;
this.isDestroyed = false;
this.HOME_POS = new THREE.Vector3();
this.HOME_TARGET = new THREE.Vector3();
// 自动旋转状态
this._isUserInteracting = false;
this._idleTimer = null;
this._autoRotateTime = 0;
this._clock = new THREE.Clock();
this._lastCameraPos = new THREE.Vector3();
this._lastCameraQuat = new THREE.Quaternion();
this._isAutoRotating = false;
this._spherical = new THREE.Spherical();
this._quat = new THREE.Quaternion();
this._quatInverse = new THREE.Quaternion();
this._offset = new THREE.Vector3();
this._init();
}
/** 初始化场景 */
_init() {
const { options } = this;
const aspect = this.container.clientWidth / this.container.clientHeight;
const deviceType = this._getDeviceType();
// 1. 场景
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(options.backgroundColor);
// 2. 相机
const fov = deviceType === "desktop" ? options.fov : options.fovMobile;
this.camera = new THREE.PerspectiveCamera(fov, aspect, options.near, options.far);
this.camera.position.copy(options.initialPosition);
// 3. 渲染器
this.renderer = new THREE.WebGLRenderer({
antialias: false,
alpha: false,
canvas: options.canvas || undefined,
});
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1;
if (!options.canvas) {
this.container.appendChild(this.renderer.domElement);
}
// 4. 控制器
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = options.dampingFactor;
this.controls.maxDistance = options.maxDistance;
this.controls.minDistance = options.minDistance;
this.controls.minPolarAngle = options.minPolarAngle;
this.controls.maxPolarAngle = options.maxPolarAngle;
this.controls.minAzimuthAngle = options.minAzimuthAngle;
this.controls.maxAzimuthAngle = options.maxAzimuthAngle;
this.controls.target.set(0, 0, 0);
this.controls.object.up.set(0, 0, 0);
if (!options.enablePan) {
this.controls.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: null,
};
}
this.controls.update();
// 保存初始视角
this.HOME_POS.copy(this.camera.position);
this.HOME_TARGET.copy(this.controls.target);
// 初始化自动旋转所需的四元数(用于坐标转换)
this._quat.setFromUnitVectors(this.camera.up, new THREE.Vector3(0, 1, 0));
this._quatInverse.copy(this._quat).invert();
// 立即启动自动旋转(无需等待模型加载)
if (this.options.autoRotate) {
this._updateSphericalFromCamera();
}
// 5. 监听交互事件
this._setupInteractionListeners();
// 6. Spark渲染器
this.spark = new SparkRenderer({
renderer: this.renderer,
maxStdDev: options.maxStdDev,
sortRadial: options.sortRadial,
enableLod: options.enableLod,
lodSplatScale: options.lodSplatScale,
});
this.scene.add(this.spark);
// 7. 窗口大小变化监听
this._onResize = this._handleResize.bind(this);
window.addEventListener("resize", this._onResize);
// 8. 启动渲染循环
this._startRenderLoop();
// 9. 加载默认模型
if (options.defaultModelUrl) {
this.loadModel(options.defaultModelUrl, {
autoFit: true,
onComplete: options.onLoad,
});
}
}
/** 设置交互监听器 */
_setupInteractionListeners() {
// 用户开始交互时暂停自动旋转
this.controls.addEventListener("start", () => {
this._isUserInteracting = true;
this._isAutoRotating = false;
if (this._idleTimer) {
clearTimeout(this._idleTimer);
this._idleTimer = null;
}
});
// 用户结束交互时启动空闲计时器
this.controls.addEventListener("end", () => {
this._isUserInteracting = false;
// 延迟恢复自动旋转,让用户有时间观察
this._idleTimer = setTimeout(() => {
if (!this._isUserInteracting && this.options.autoRotate) {
this._startAutoRotateFromCurrent();
}
}, this.options.autoRotateIdleDelay);
});
}
/** 从当前相机位置开始自动旋转 */
_startAutoRotateFromCurrent() {
if (!this.controls || this.isDestroyed) return;
// 记录当前相机位置对应的 spherical 坐标,作为自动旋转的起点
this._updateSphericalFromCamera();
this._isAutoRotating = true;
}
/** 根据当前相机位置更新 spherical 坐标 */
_updateSphericalFromCamera() {
this._offset.copy(this.camera.position).sub(this.controls.target);
this._offset.applyQuaternion(this._quat);
this._spherical.setFromVector3(this._offset);
}
/** 应用 spherical 坐标到相机 */
_applySphericalToCamera() {
this._offset.setFromSpherical(this._spherical);
this._offset.applyQuaternion(this._quatInverse);
this.camera.position.copy(this.controls.target).add(this._offset);
this.camera.lookAt(this.controls.target);
}
/** 执行自动旋转 */
_doAutoRotate(deltaTime) {
if (!this._isAutoRotating || this._isUserInteracting) return;
const opts = this.options;
const speed = opts.autoRotateSpeed;
// 计算角度范围
const thetaRange = opts.maxAzimuthAngle - opts.minAzimuthAngle;
const phiRange = opts.maxPolarAngle - opts.minPolarAngle;
// 使用正弦波在限制范围内摆动,覆盖整个区域
// theta: 水平方向(左右)
// phi: 垂直方向(上下)
// 使用不同频率创造"8字形"或椭圆轨迹,确保覆盖整个可见区域
this._autoRotateTime += deltaTime * speed;
// 水平方向:完整遍历 minAzimuthAngle ~ maxAzimuthAngle
const thetaCenter = (opts.minAzimuthAngle + opts.maxAzimuthAngle) / 2;
const thetaAmp = thetaRange / 2 * 0.95; // 留一点边距避免卡在边界
const targetTheta = thetaCenter + Math.sin(this._autoRotateTime) * thetaAmp;
// 垂直方向:完整遍历 minPolarAngle ~ maxPolarAngle
// 使用 2 倍频率,确保上下也能充分覆盖
const phiCenter = (opts.minPolarAngle + opts.maxPolarAngle) / 2;
const phiAmp = phiRange / 2 * 0.95;
const targetPhi = phiCenter + Math.sin(this._autoRotateTime * 1.618) * phiAmp;
// 平滑插值到目标角度(避免突变)
const lerpFactor = Math.min(deltaTime * 2.0, 1.0);
this._spherical.theta += (targetTheta - this._spherical.theta) * lerpFactor;
this._spherical.phi += (targetPhi - this._spherical.phi) * lerpFactor;
// 限制在范围内
this._spherical.theta = Math.max(
opts.minAzimuthAngle,
Math.min(opts.maxAzimuthAngle, this._spherical.theta)
);
this._spherical.phi = Math.max(
opts.minPolarAngle,
Math.min(opts.maxPolarAngle, this._spherical.phi)
);
this._spherical.makeSafe();
// 应用到相机
this._applySphericalToCamera();
}
/** 渲染循环 */
_startRenderLoop() {
const animate = () => {
if (this.isDestroyed) return;
const deltaTime = this._clock.getDelta();
// 自动旋转(仅在用户未交互时)
if (this.options.autoRotate && !this._isUserInteracting) {
this._doAutoRotate(deltaTime);
}
this.controls.update();
this.renderer.render(this.scene, this.camera);
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}
/** 窗口大小变化处理 */
_handleResize() {
if (!this.container || this.isDestroyed) return;
const w = this.container.clientWidth;
const h = this.container.clientHeight;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
/** 获取设备类型 */
_getDeviceType() {
const ua = navigator.userAgent;
const isTablet = /iPad|Android(?!.*Mobile)/i.test(ua) ||
(/Android/i.test(ua) && window.innerWidth > 768);
if (/Mobi|Android|iPhone/i.test(ua) && !isTablet) return "mobile";
if (isTablet) return "tablet";
return "desktop";
}
/**
* 获取模型包围盒
* @returns {THREE.Box3|null}
*/
getModelBounds() {
if (!this.splatMesh) return null;
const box = new THREE.Box3();
this.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(this.splatMesh);
}
return box;
}
/**
* 适配相机到模型
*/
fitCameraToModel() {
const box = this.getModelBounds();
if (!box || box.isEmpty()) {
console.warn("[SparkSplatViewer] 无法计算包围盒,使用默认范围");
const size = 0.3;
const dist = size / (2 * Math.tan((this.camera.fov * Math.PI) / 360));
this.camera.position.set(0, 0, dist * 3);
this.controls.target.set(0, 0, 0);
this.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);
this.controls.target.copy(center);
const fovRad = (this.camera.fov * Math.PI) / 180;
const dist = maxDim / (2 * Math.tan(fovRad / 2)) * 0.25;
const newPos = center.clone();
newPos.z += Math.max(dist, 0.1);
this.camera.position.copy(newPos);
this.camera.lookAt(center);
this.controls.update();
this.controls.maxDistance = dist * 3;
this.controls.minDistance = maxDim * 0.1;
this.HOME_POS.copy(this.camera.position);
this.HOME_TARGET.copy(this.controls.target);
// 自动旋转从当前位置继续(模型加载完成后立即启动)
if (this.options.autoRotate) {
this._startAutoRotateFromCurrent();
}
}
/**
* 加载新模型(替换旧模型)
* @param {string} url - 模型文件路径
* @param {Object} loadOptions - 加载配置
* @param {boolean} loadOptions.autoFit - 是否自动调整相机视角,默认 true
* @param {number} loadOptions.zoomDuration - 缩放动画时长(ms),默认 500
* @param {number} loadOptions.desktopZoom - 桌面端目标 zoom,默认 2.5
* @param {number} loadOptions.mobileZoom - 移动端目标 zoom,默认 0.55
* @param {number} loadOptions.tabletZoom - 平板端目标 zoom,默认 1.0
* @param {Function} loadOptions.onComplete - 加载完成回调
* @param {Function} loadOptions.onError - 加载失败回调
* @param {Function} loadOptions.onProgress - 加载进度回调
* @returns {SplatMesh} 新创建的 SplatMesh 实例
*/
loadModel(url, loadOptions = {}) {
const {
autoFit = true,
zoomDuration = this.options.zoomDuration,
desktopZoom = this.options.desktopZoom,
mobileZoom = this.options.mobileZoom,
tabletZoom = this.options.tabletZoom,
onComplete = null,
onError = null,
onProgress = null,
} = loadOptions;
// 1. 移除旧模型
this._disposeCurrentModel();
// 2. 创建新模型
const newMesh = new SplatMesh({
url: url,
onLoad: (mesh) => {
console.log(`[SparkSplatViewer] 模型加载完成: ${url}`);
// 旋转修正
mesh.rotation.x = this.options.modelRotationX;
if (autoFit) {
this.fitCameraToModel();
const deviceType = this._getDeviceType();
let targetZoom = desktopZoom;
if (deviceType === "mobile") targetZoom = mobileZoom;
else if (deviceType === "tablet") targetZoom = tabletZoom;
this.animateZoomTo(targetZoom, zoomDuration);
}
// 模型加载完成后立即启动自动旋转
if (this.options.autoRotate) {
this._startAutoRotateFromCurrent();
}
this.controls.update();
if (onComplete) onComplete(mesh);
if (this.options.onLoad) this.options.onLoad(mesh);
},
onProgress: (event) => {
if (event.lengthComputable) {
const pct = ((event.loaded / event.total) * 100).toFixed(1);
console.log(`[SparkSplatViewer] 加载进度: ${pct}%`);
}
if (onProgress) onProgress(event);
if (this.options.onProgress) this.options.onProgress(event);
},
onError: (err) => {
console.error(`[SparkSplatViewer] 模型加载失败: ${url}`, err);
if (onError) onError(err);
if (this.options.onError) this.options.onError(err);
},
});
// 3. 添加到场景
this.scene.add(newMesh);
this.splatMesh = newMesh;
return newMesh;
}
/** 清理当前模型资源 */
_disposeCurrentModel() {
if (!this.splatMesh) return;
// 停止进行中的 zoom 动画
if (this.zoomAnimId) {
cancelAnimationFrame(this.zoomAnimId);
this.zoomAnimId = null;
}
// 从场景中移除
this.scene.remove(this.splatMesh);
// 清理资源
this.splatMesh.traverse((child) => {
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach((m) => {
if (m.map) m.map.dispose();
m.dispose();
});
} else {
if (child.material.map) child.material.map.dispose();
child.material.dispose();
}
}
});
if (typeof this.splatMesh.dispose === "function") {
this.splatMesh.dispose();
}
this.splatMesh = null;
console.log("[SparkSplatViewer] 旧模型已移除,资源已清理");
}
/**
* 平滑过渡 zoom
* @param {number} targetZoom - 目标缩放值
* @param {number} duration - 动画时长(ms)
*/
animateZoomTo(targetZoom, duration = 500) {
if (this.zoomAnimId) cancelAnimationFrame(this.zoomAnimId);
const startZoom = this.camera.zoom;
const startTime = performance.now();
const 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;
this.camera.zoom = startZoom + (targetZoom - startZoom) * ease;
this.camera.updateProjectionMatrix();
if (t < 1) {
this.zoomAnimId = requestAnimationFrame(step);
} else {
this.zoomAnimId = null;
}
};
this.zoomAnimId = requestAnimationFrame(step);
}
/** 重置相机到初始位置 */
resetCamera() {
this.camera.position.copy(this.HOME_POS);
this.controls.target.copy(this.HOME_TARGET);
this.controls.update();
if (this.options.autoRotate) {
this._updateSphericalFromCamera();
}
}
/** 获取当前相机信息 */
getCameraInfo() {
const p = this.camera.position;
const t = this.controls.target;
return {
position: { x: p.x, y: p.y, z: p.z },
target: { x: t.x, y: t.y, z: t.z },
zoom: this.camera.zoom,
};
}
/** 销毁组件 */
destroy() {
this.isDestroyed = true;
if (this.zoomAnimId) {
cancelAnimationFrame(this.zoomAnimId);
this.zoomAnimId = null;
}
if (this._idleTimer) {
clearTimeout(this._idleTimer);
this._idleTimer = null;
}
this._disposeCurrentModel();
if (this.spark) {
this.scene.remove(this.spark);
this.spark = null;
}
if (this.controls) {
this.controls.dispose();
this.controls = null;
}
if (this.renderer) {
this.renderer.dispose();
if (this.renderer.domElement.parentNode) {
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
}
this.renderer = null;
}
window.removeEventListener("resize", this._onResize);
this.scene = null;
this.camera = null;
this.container = null;
console.log("[SparkSplatViewer] 组件已销毁");
}
}
export default SparkSplatViewer;
\ No newline at end of file
<!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>
* { margin: 0; padding: 0; }
body { overflow: hidden; background: #1a1a2e; font-family: 'Segoe UI', Arial, sans-serif; }
canvas { display: block; }
</style>
</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
};
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',
onLoad: (mesh) => {
console.log("模型加载完成");
fitCameraToModel();
const state = getDeviceType();
state === "desktop" ? animateZoomTo(2.2, 500) : animateZoomTo(0.55, 500);
// 加载完成后,把相机从当前位置往模型方向(-Z)挪
controls.update();
},
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);
}
});
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);
if (/Mobi|Android|iPhone/i.test(ua) && !isTablet) return 'mobile';
if (isTablet) return 'tablet';
return 'desktop';
}
window.addEventListener("resize", () => {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});
</script>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3D高斯泼溅 - SparkSplatViewer</title>
<style>
* { 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>
<div id="viewer-container"></div>
<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("默认模型加载完成", mesh);
},
onError: (err) => {
console.error("模型加载失败", err);
},
});
// 暴露到全局,方便调试和外部调用
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, // 进度回调
});
};
// 使用示例:
// loadNewModel("./new-model.spz");
// loadNewModel("./new-model.spz", { autoFit: true, desktopZoom: 3.0 });
// 其他可用方法:
// viewer.resetCamera(); // 重置相机
// viewer.fitCameraToModel(); // 适配相机到模型
// viewer.getCameraInfo(); // 获取相机信息
// viewer.animateZoomTo(2.0, 800); // 平滑缩放到指定值
// viewer.destroy(); // 销毁组件
// 获取模型包围盒
// const bounds = viewer.getModelBounds();
</script>
</body>
</html>
\ No newline at end of file
<!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>
* { margin: 0; padding: 0; }
body { overflow: hidden; background: #1a1a2e; font-family: 'Segoe UI', Arial, sans-serif; }
canvas { display: block; }
</style>
<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 state = getDeviceType();
let perspCamera;
if(state === "desktop") {
perspCamera = new THREE.PerspectiveCamera(45, aspect, 0.001, 10000);
}else{
perspCamera = new THREE.PerspectiveCamera(20, aspect, 0.001, 10000);
}
// 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
};
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);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 5. 模型管理(支持加载新模型替换旧模型)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
let splatMesh = null;
function initDefaultModel() {
splatMesh = new SplatMesh({
url: './A11.spz',
onLoad: (mesh) => {
console.log("模型加载完成");
mesh.rotation.x = -Math.PI;
fitCameraToModel();
state === "desktop" ? animateZoomTo(2.5, 500) : animateZoomTo(0.55, 500);
controls.update();
},
onProgress: (event) => {
if (event.lengthComputable) {
const pct = ((event.loaded / event.total) * 100).toFixed(1);
console.log(`加载进度: ${pct}%`);
}
}
});
scene.add(splatMesh);
window.__splat = splatMesh;
}
// 初始化默认模型
initDefaultModel();
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 6. 加载新模型(替换旧模型)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
/**
* 加载新模型并替换当前模型
* @param {string} url - 新模型文件路径
* @param {Object} options - 配置选项
* @param {boolean} options.autoFit - 是否自动调整相机视角,默认 true
* @param {number} options.zoomDuration - 缩放动画时长(ms),默认 500
* @param {number} options.desktopZoom - 桌面端目标 zoom,默认 2.2
* @param {number} options.mobileZoom - 移动端目标 zoom,默认 0.55
* @param {Function} options.onComplete - 加载完成回调
* @param {Function} options.onError - 加载失败回调
* @param {Function} options.onProgress - 加载进度回调
* @returns {SplatMesh} 新创建的 SplatMesh 实例
*/
function loadNewModel(url, options = {}) {
const {
autoFit = true,
zoomDuration = 500,
desktopZoom = 2.2,
mobileZoom = 0.55,
onComplete = null,
onError = null,
onProgress = null
} = options;
// 1. 移除旧模型
if (splatMesh) {
// 停止进行中的 zoom 动画
if (zoomAnimId) {
cancelAnimationFrame(zoomAnimId);
zoomAnimId = null;
}
// 从场景中移除
scene.remove(splatMesh);
// 清理资源(防止内存泄漏)
splatMesh.traverse((child) => {
if (child.geometry) {
child.geometry.dispose();
}
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(m => {
if (m.map) m.map.dispose();
m.dispose();
});
} else {
if (child.material.map) child.material.map.dispose();
child.material.dispose();
}
}
});
// 如果 splatMesh 自身有 dispose 方法,也调用一下
if (typeof splatMesh.dispose === 'function') {
splatMesh.dispose();
}
console.log("旧模型已移除,资源已清理");
}
// 2. 创建新模型
const newMesh = new SplatMesh({
url: url,
onLoad: (mesh) => {
console.log(`新模型加载完成: ${url}`);
// 旋转修正(与原始代码保持一致)
mesh.rotation.x = -Math.PI;
if (autoFit) {
fitCameraToModel();
const state = getDeviceType();
const targetZoom = state === "desktop" ? desktopZoom : mobileZoom;
animateZoomTo(targetZoom, zoomDuration);
}
controls.update();
if (onComplete) onComplete(mesh);
},
onProgress: (event) => {
if (event.lengthComputable) {
const pct = ((event.loaded / event.total) * 100).toFixed(1);
console.log(`加载进度: ${pct}%`);
}
if (onProgress) onProgress(event);
},
onError: (err) => {
console.error(`模型加载失败: ${url}`, err);
if (onError) onError(err);
}
});
// 3. 添加到场景并更新引用
scene.add(newMesh);
// 更新全局引用(用于调试和后续操作)
splatMesh = newMesh;
window.__splat = splatMesh;
return newMesh;
}
// 暴露到全局
window.loadNewModel = loadNewModel;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 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;
}
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 轴方向观察(标准前视图)
const newPos = center.clone();
newPos.z += Math.max(dist, 0.1);
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();
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 8. 渲染循环
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
renderer.setAnimationLoop(() => {
controls.update();
renderer.render(scene, camera);
});
// 暴露到全局供调试
window.__scene = scene;
window.__camera = camera;
window.__controls = controls;
// 平滑过渡 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);
if (/Mobi|Android|iPhone/i.test(ua) && !isTablet) return 'mobile';
if (isTablet) return 'tablet';
return 'desktop';
}
window.addEventListener("resize", () => {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});
</script>
</body>
</html>
\ No newline at end of file
<!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>
* {
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>
<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"
}
}
</script>
<base target="_blank">
</head>
<body>
<!-- 控制面板 -->
<div class="panel" id="panel" style="display:none">
<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" style="display: none;">
<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>
</body>
</html>
\ No newline at end of file
<!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>
* {
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>
<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"
}
}
</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>
</body>
</html>
\ No newline at end of file
import {
Controls,
MOUSE,
Quaternion,
Spherical,
TOUCH,
Vector2,
Vector3,
Plane,
Ray,
MathUtils
} from './three.module.js';
/**
* Fires when the camera has been transformed by the controls.
*
* @event OrbitControls#change
* @type {Object}
*/
const _changeEvent = { type: 'change' };
/**
* Fires when an interaction was initiated.
*
* @event OrbitControls#start
* @type {Object}
*/
const _startEvent = { type: 'start' };
/**
* Fires when an interaction has finished.
*
* @event OrbitControls#end
* @type {Object}
*/
const _endEvent = { type: 'end' };
const _ray = new Ray();
const _plane = new Plane();
const _TILT_LIMIT = Math.cos( 70 * MathUtils.DEG2RAD );
const _v = new Vector3();
const _twoPI = 2 * Math.PI;
const _STATE = {
NONE: - 1,
ROTATE: 0,
DOLLY: 1,
PAN: 2,
TOUCH_ROTATE: 3,
TOUCH_PAN: 4,
TOUCH_DOLLY_PAN: 5,
TOUCH_DOLLY_ROTATE: 6
};
const _EPS = 0.000001;
/**
* Orbit controls allow the camera to orbit around a target.
*
* OrbitControls performs orbiting, dollying (zooming), and panning. Unlike {@link TrackballControls},
* it maintains the "up" direction `object.up` (+Y by default).
*
* - Orbit: Left mouse / touch: one-finger move.
* - Zoom: Middle mouse, or mousewheel / touch: two-finger spread or squish.
* - Pan: Right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move.
*
* ```js
* const controls = new OrbitControls( camera, renderer.domElement );
*
* // controls.update() must be called after any manual changes to the camera's transform
* camera.position.set( 0, 20, 100 );
* controls.update();
*
* function animate() {
*
* // required if controls.enableDamping or controls.autoRotate are set to true
* controls.update();
*
* renderer.render( scene, camera );
*
* }
* ```
*
* @augments Controls
* @three_import import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
*/
class OrbitControls extends Controls {
/**
* Constructs a new controls instance.
*
* @param {Object3D} object - The object that is managed by the controls.
* @param {?HTMLDOMElement} domElement - The HTML element used for event listeners.
*/
constructor( object, domElement = null ) {
super( object, domElement );
this.state = _STATE.NONE;
/**
* The focus point of the controls, the `object` orbits around this.
* It can be updated manually at any point to change the focus of the controls.
*
* @type {Vector3}
*/
this.target = new Vector3();
/**
* The focus point of the `minTargetRadius` and `maxTargetRadius` limits.
* It can be updated manually at any point to change the center of interest
* for the `target`.
*
* @type {Vector3}
*/
this.cursor = new Vector3();
/**
* How far you can dolly in (perspective camera only).
*
* @type {number}
* @default 0
*/
this.minDistance = 0;
/**
* How far you can dolly out (perspective camera only).
*
* @type {number}
* @default Infinity
*/
this.maxDistance = Infinity;
/**
* How far you can zoom in (orthographic camera only).
*
* @type {number}
* @default 0
*/
this.minZoom = 0;
/**
* How far you can zoom out (orthographic camera only).
*
* @type {number}
* @default Infinity
*/
this.maxZoom = Infinity;
/**
* How close you can get the target to the 3D `cursor`.
*
* @type {number}
* @default 0
*/
this.minTargetRadius = 0;
/**
* How far you can move the target from the 3D `cursor`.
*
* @type {number}
* @default Infinity
*/
this.maxTargetRadius = Infinity;
/**
* How far you can orbit vertically, lower limit. Range is `[0, Math.PI]` radians.
*
* @type {number}
* @default 0
*/
this.minPolarAngle = 0;
/**
* How far you can orbit vertically, upper limit. Range is `[0, Math.PI]` radians.
*
* @type {number}
* @default Math.PI
*/
this.maxPolarAngle = Math.PI;
/**
* How far you can orbit horizontally, lower limit. If set, the interval `[ min, max ]`
* must be a sub-interval of `[ - 2 PI, 2 PI ]`, with `( max - min < 2 PI )`.
*
* @type {number}
* @default -Infinity
*/
this.minAzimuthAngle = - Infinity;
/**
* How far you can orbit horizontally, upper limit. If set, the interval `[ min, max ]`
* must be a sub-interval of `[ - 2 PI, 2 PI ]`, with `( max - min < 2 PI )`.
*
* @type {number}
* @default -Infinity
*/
this.maxAzimuthAngle = Infinity;
/**
* Set to `true` to enable damping (inertia), which can be used to give a sense of weight
* to the controls. Note that if this is enabled, you must call `update()` in your animation
* loop.
*
* @type {boolean}
* @default false
*/
this.enableDamping = false;
/**
* The damping inertia used if `enableDamping` is set to `true`.
*
* Note that for this to work, you must call `update()` in your animation loop.
*
* @type {number}
* @default 0.05
*/
this.dampingFactor = 0.05;
/**
* Enable or disable zooming (dollying) of the camera.
*
* @type {boolean}
* @default true
*/
this.enableZoom = true;
/**
* Speed of zooming / dollying.
*
* @type {number}
* @default 1
*/
this.zoomSpeed = 1.0;
/**
* Enable or disable horizontal and vertical rotation of the camera.
*
* Note that it is possible to disable a single axis by setting the min and max of the
* `minPolarAngle` or `minAzimuthAngle` to the same value, which will cause the vertical
* or horizontal rotation to be fixed at that value.
*
* @type {boolean}
* @default true
*/
this.enableRotate = true;
/**
* Speed of rotation.
*
* @type {number}
* @default 1
*/
this.rotateSpeed = 1.0;
/**
* How fast to rotate the camera when the keyboard is used.
*
* @type {number}
* @default 1
*/
this.keyRotateSpeed = 1.0;
/**
* Enable or disable camera panning.
*
* @type {boolean}
* @default true
*/
this.enablePan = true;
/**
* Speed of panning.
*
* @type {number}
* @default 1
*/
this.panSpeed = 1.0;
/**
* Defines how the camera's position is translated when panning. If `true`, the camera pans
* in screen space. Otherwise, the camera pans in the plane orthogonal to the camera's up
* direction.
*
* @type {boolean}
* @default true
*/
this.screenSpacePanning = true;
/**
* How fast to pan the camera when the keyboard is used in
* pixels per keypress.
*
* @type {number}
* @default 7
*/
this.keyPanSpeed = 7.0;
/**
* Setting this property to `true` allows to zoom to the cursor's position.
*
* @type {boolean}
* @default false
*/
this.zoomToCursor = false;
/**
* Set to true to automatically rotate around the target
*
* Note that if this is enabled, you must call `update()` in your animation loop.
* If you want the auto-rotate speed to be independent of the frame rate (the refresh
* rate of the display), you must pass the time `deltaTime`, in seconds, to `update()`.
*
* @type {boolean}
* @default false
*/
this.autoRotate = false;
/**
* How fast to rotate around the target if `autoRotate` is `true`. The default equates to 30 seconds
* per orbit at 60fps.
*
* Note that if `autoRotate` is enabled, you must call `update()` in your animation loop.
*
* @type {number}
* @default 2
*/
this.autoRotateSpeed = 2.0;
/**
* This object contains references to the keycodes for controlling camera panning.
*
* ```js
* controls.keys = {
* LEFT: 'ArrowLeft', //left arrow
* UP: 'ArrowUp', // up arrow
* RIGHT: 'ArrowRight', // right arrow
* BOTTOM: 'ArrowDown' // down arrow
* }
* ```
* @type {Object}
*/
this.keys = { LEFT: 'ArrowLeft', UP: 'ArrowUp', RIGHT: 'ArrowRight', BOTTOM: 'ArrowDown' };
/**
* This object contains references to the mouse actions used by the controls.
*
* ```js
* controls.mouseButtons = {
* LEFT: THREE.MOUSE.ROTATE,
* MIDDLE: THREE.MOUSE.DOLLY,
* RIGHT: THREE.MOUSE.PAN
* }
* ```
* @type {Object}
*/
this.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.PAN };
/**
* This object contains references to the touch actions used by the controls.
*
* ```js
* controls.mouseButtons = {
* ONE: THREE.TOUCH.ROTATE,
* TWO: THREE.TOUCH.DOLLY_PAN
* }
* ```
* @type {Object}
*/
this.touches = { ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN };
/**
* Used internally by `saveState()` and `reset()`.
*
* @type {Vector3}
*/
this.target0 = this.target.clone();
/**
* Used internally by `saveState()` and `reset()`.
*
* @type {Vector3}
*/
this.position0 = this.object.position.clone();
/**
* Used internally by `saveState()` and `reset()`.
*
* @type {number}
*/
this.zoom0 = this.object.zoom;
// the target DOM element for key events
this._domElementKeyEvents = null;
// internals
this._lastPosition = new Vector3();
this._lastQuaternion = new Quaternion();
this._lastTargetPosition = new Vector3();
// so camera.up is the orbit axis
this._quat = new Quaternion().setFromUnitVectors( object.up, new Vector3( 0, 1, 0 ) );
this._quatInverse = this._quat.clone().invert();
// current position in spherical coordinates
this._spherical = new Spherical();
this._sphericalDelta = new Spherical();
this._scale = 1;
this._panOffset = new Vector3();
this._rotateStart = new Vector2();
this._rotateEnd = new Vector2();
this._rotateDelta = new Vector2();
this._panStart = new Vector2();
this._panEnd = new Vector2();
this._panDelta = new Vector2();
this._dollyStart = new Vector2();
this._dollyEnd = new Vector2();
this._dollyDelta = new Vector2();
this._dollyDirection = new Vector3();
this._mouse = new Vector2();
this._performCursorZoom = false;
this._pointers = [];
this._pointerPositions = {};
this._controlActive = false;
// event listeners
this._onPointerMove = onPointerMove.bind( this );
this._onPointerDown = onPointerDown.bind( this );
this._onPointerUp = onPointerUp.bind( this );
this._onContextMenu = onContextMenu.bind( this );
this._onMouseWheel = onMouseWheel.bind( this );
this._onKeyDown = onKeyDown.bind( this );
this._onTouchStart = onTouchStart.bind( this );
this._onTouchMove = onTouchMove.bind( this );
this._onMouseDown = onMouseDown.bind( this );
this._onMouseMove = onMouseMove.bind( this );
this._interceptControlDown = interceptControlDown.bind( this );
this._interceptControlUp = interceptControlUp.bind( this );
//
if ( this.domElement !== null ) {
this.connect( this.domElement );
}
this.update();
}
connect( element ) {
super.connect( element );
this.domElement.addEventListener( 'pointerdown', this._onPointerDown );
this.domElement.addEventListener( 'pointercancel', this._onPointerUp );
this.domElement.addEventListener( 'contextmenu', this._onContextMenu );
this.domElement.addEventListener( 'wheel', this._onMouseWheel, { passive: false } );
const document = this.domElement.getRootNode(); // offscreen canvas compatibility
document.addEventListener( 'keydown', this._interceptControlDown, { passive: true, capture: true } );
this.domElement.style.touchAction = 'none'; // disable touch scroll
}
disconnect() {
this.domElement.removeEventListener( 'pointerdown', this._onPointerDown );
this.domElement.removeEventListener( 'pointermove', this._onPointerMove );
this.domElement.removeEventListener( 'pointerup', this._onPointerUp );
this.domElement.removeEventListener( 'pointercancel', this._onPointerUp );
this.domElement.removeEventListener( 'wheel', this._onMouseWheel );
this.domElement.removeEventListener( 'contextmenu', this._onContextMenu );
this.stopListenToKeyEvents();
const document = this.domElement.getRootNode(); // offscreen canvas compatibility
document.removeEventListener( 'keydown', this._interceptControlDown, { capture: true } );
this.domElement.style.touchAction = 'auto';
}
dispose() {
this.disconnect();
}
/**
* Get the current vertical rotation, in radians.
*
* @return {number} The current vertical rotation, in radians.
*/
getPolarAngle() {
return this._spherical.phi;
}
/**
* Get the current horizontal rotation, in radians.
*
* @return {number} The current horizontal rotation, in radians.
*/
getAzimuthalAngle() {
return this._spherical.theta;
}
/**
* Returns the distance from the camera to the target.
*
* @return {number} The distance from the camera to the target.
*/
getDistance() {
return this.object.position.distanceTo( this.target );
}
/**
* Adds key event listeners to the given DOM element.
* `window` is a recommended argument for using this method.
*
* @param {HTMLDOMElement} domElement - The DOM element
*/
listenToKeyEvents( domElement ) {
domElement.addEventListener( 'keydown', this._onKeyDown );
this._domElementKeyEvents = domElement;
}
/**
* Removes the key event listener previously defined with `listenToKeyEvents()`.
*/
stopListenToKeyEvents() {
if ( this._domElementKeyEvents !== null ) {
this._domElementKeyEvents.removeEventListener( 'keydown', this._onKeyDown );
this._domElementKeyEvents = null;
}
}
/**
* Save the current state of the controls. This can later be recovered with `reset()`.
*/
saveState() {
this.target0.copy( this.target );
this.position0.copy( this.object.position );
this.zoom0 = this.object.zoom;
}
/**
* Reset the controls to their state from either the last time the `saveState()`
* was called, or the initial state.
*/
reset() {
this.target.copy( this.target0 );
this.object.position.copy( this.position0 );
this.object.zoom = this.zoom0;
this.object.updateProjectionMatrix();
this.dispatchEvent( _changeEvent );
this.update();
this.state = _STATE.NONE;
}
update( deltaTime = null ) {
const position = this.object.position;
_v.copy( position ).sub( this.target );
// rotate offset to "y-axis-is-up" space
_v.applyQuaternion( this._quat );
// angle from z-axis around y-axis
this._spherical.setFromVector3( _v );
if ( this.autoRotate && this.state === _STATE.NONE ) {
this._rotateLeft( this._getAutoRotationAngle( deltaTime ) );
}
if ( this.enableDamping ) {
this._spherical.theta += this._sphericalDelta.theta * this.dampingFactor;
this._spherical.phi += this._sphericalDelta.phi * this.dampingFactor;
} else {
this._spherical.theta += this._sphericalDelta.theta;
this._spherical.phi += this._sphericalDelta.phi;
}
// restrict theta to be between desired limits
let min = this.minAzimuthAngle;
let max = this.maxAzimuthAngle;
if ( isFinite( min ) && isFinite( max ) ) {
if ( min < - Math.PI ) min += _twoPI; else if ( min > Math.PI ) min -= _twoPI;
if ( max < - Math.PI ) max += _twoPI; else if ( max > Math.PI ) max -= _twoPI;
if ( min <= max ) {
this._spherical.theta = Math.max( min, Math.min( max, this._spherical.theta ) );
} else {
this._spherical.theta = ( this._spherical.theta > ( min + max ) / 2 ) ?
Math.max( min, this._spherical.theta ) :
Math.min( max, this._spherical.theta );
}
}
// restrict phi to be between desired limits
this._spherical.phi = Math.max( this.minPolarAngle, Math.min( this.maxPolarAngle, this._spherical.phi ) );
this._spherical.makeSafe();
// move target to panned location
if ( this.enableDamping === true ) {
this.target.addScaledVector( this._panOffset, this.dampingFactor );
} else {
this.target.add( this._panOffset );
}
// Limit the target distance from the cursor to create a sphere around the center of interest
this.target.sub( this.cursor );
this.target.clampLength( this.minTargetRadius, this.maxTargetRadius );
this.target.add( this.cursor );
let zoomChanged = false;
// adjust the camera position based on zoom only if we're not zooming to the cursor or if it's an ortho camera
// we adjust zoom later in these cases
if ( this.zoomToCursor && this._performCursorZoom || this.object.isOrthographicCamera ) {
this._spherical.radius = this._clampDistance( this._spherical.radius );
} else {
const prevRadius = this._spherical.radius;
this._spherical.radius = this._clampDistance( this._spherical.radius * this._scale );
zoomChanged = prevRadius != this._spherical.radius;
}
_v.setFromSpherical( this._spherical );
// rotate offset back to "camera-up-vector-is-up" space
_v.applyQuaternion( this._quatInverse );
position.copy( this.target ).add( _v );
this.object.lookAt( this.target );
if ( this.enableDamping === true ) {
this._sphericalDelta.theta *= ( 1 - this.dampingFactor );
this._sphericalDelta.phi *= ( 1 - this.dampingFactor );
this._panOffset.multiplyScalar( 1 - this.dampingFactor );
} else {
this._sphericalDelta.set( 0, 0, 0 );
this._panOffset.set( 0, 0, 0 );
}
// adjust camera position
if ( this.zoomToCursor && this._performCursorZoom ) {
let newRadius = null;
if ( this.object.isPerspectiveCamera ) {
// move the camera down the pointer ray
// this method avoids floating point error
const prevRadius = _v.length();
newRadius = this._clampDistance( prevRadius * this._scale );
const radiusDelta = prevRadius - newRadius;
this.object.position.addScaledVector( this._dollyDirection, radiusDelta );
this.object.updateMatrixWorld();
zoomChanged = !! radiusDelta;
} else if ( this.object.isOrthographicCamera ) {
// adjust the ortho camera position based on zoom changes
const mouseBefore = new Vector3( this._mouse.x, this._mouse.y, 0 );
mouseBefore.unproject( this.object );
const prevZoom = this.object.zoom;
this.object.zoom = Math.max( this.minZoom, Math.min( this.maxZoom, this.object.zoom / this._scale ) );
this.object.updateProjectionMatrix();
zoomChanged = prevZoom !== this.object.zoom;
const mouseAfter = new Vector3( this._mouse.x, this._mouse.y, 0 );
mouseAfter.unproject( this.object );
this.object.position.sub( mouseAfter ).add( mouseBefore );
this.object.updateMatrixWorld();
newRadius = _v.length();
} else {
console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - zoom to cursor disabled.' );
this.zoomToCursor = false;
}
// handle the placement of the target
if ( newRadius !== null ) {
if ( this.screenSpacePanning ) {
// position the orbit target in front of the new camera position
this.target.set( 0, 0, - 1 )
.transformDirection( this.object.matrix )
.multiplyScalar( newRadius )
.add( this.object.position );
} else {
// get the ray and translation plane to compute target
_ray.origin.copy( this.object.position );
_ray.direction.set( 0, 0, - 1 ).transformDirection( this.object.matrix );
// if the camera is 20 degrees above the horizon then don't adjust the focus target to avoid
// extremely large values
if ( Math.abs( this.object.up.dot( _ray.direction ) ) < _TILT_LIMIT ) {
this.object.lookAt( this.target );
} else {
_plane.setFromNormalAndCoplanarPoint( this.object.up, this.target );
_ray.intersectPlane( _plane, this.target );
}
}
}
} else if ( this.object.isOrthographicCamera ) {
const prevZoom = this.object.zoom;
this.object.zoom = Math.max( this.minZoom, Math.min( this.maxZoom, this.object.zoom / this._scale ) );
if ( prevZoom !== this.object.zoom ) {
this.object.updateProjectionMatrix();
zoomChanged = true;
}
}
this._scale = 1;
this._performCursorZoom = false;
// update condition is:
// min(camera displacement, camera rotation in radians)^2 > EPS
// using small-angle approximation cos(x/2) = 1 - x^2 / 8
if ( zoomChanged ||
this._lastPosition.distanceToSquared( this.object.position ) > _EPS ||
8 * ( 1 - this._lastQuaternion.dot( this.object.quaternion ) ) > _EPS ||
this._lastTargetPosition.distanceToSquared( this.target ) > _EPS ) {
this.dispatchEvent( _changeEvent );
this._lastPosition.copy( this.object.position );
this._lastQuaternion.copy( this.object.quaternion );
this._lastTargetPosition.copy( this.target );
return true;
}
return false;
}
_getAutoRotationAngle( deltaTime ) {
if ( deltaTime !== null ) {
return ( _twoPI / 60 * this.autoRotateSpeed ) * deltaTime;
} else {
return _twoPI / 60 / 60 * this.autoRotateSpeed;
}
}
_getZoomScale( delta ) {
const normalizedDelta = Math.abs( delta * 0.01 );
return Math.pow( 0.95, this.zoomSpeed * normalizedDelta );
}
_rotateLeft( angle ) {
this._sphericalDelta.theta -= angle;
}
_rotateUp( angle ) {
this._sphericalDelta.phi -= angle;
}
_panLeft( distance, objectMatrix ) {
_v.setFromMatrixColumn( objectMatrix, 0 ); // get X column of objectMatrix
_v.multiplyScalar( - distance );
this._panOffset.add( _v );
}
_panUp( distance, objectMatrix ) {
if ( this.screenSpacePanning === true ) {
_v.setFromMatrixColumn( objectMatrix, 1 );
} else {
_v.setFromMatrixColumn( objectMatrix, 0 );
_v.crossVectors( this.object.up, _v );
}
_v.multiplyScalar( distance );
this._panOffset.add( _v );
}
// deltaX and deltaY are in pixels; right and down are positive
_pan( deltaX, deltaY ) {
const element = this.domElement;
if ( this.object.isPerspectiveCamera ) {
// perspective
const position = this.object.position;
_v.copy( position ).sub( this.target );
let targetDistance = _v.length();
// half of the fov is center to top of screen
targetDistance *= Math.tan( ( this.object.fov / 2 ) * Math.PI / 180.0 );
// we use only clientHeight here so aspect ratio does not distort speed
this._panLeft( 2 * deltaX * targetDistance / element.clientHeight, this.object.matrix );
this._panUp( 2 * deltaY * targetDistance / element.clientHeight, this.object.matrix );
} else if ( this.object.isOrthographicCamera ) {
// orthographic
this._panLeft( deltaX * ( this.object.right - this.object.left ) / this.object.zoom / element.clientWidth, this.object.matrix );
this._panUp( deltaY * ( this.object.top - this.object.bottom ) / this.object.zoom / element.clientHeight, this.object.matrix );
} else {
// camera neither orthographic nor perspective
console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' );
this.enablePan = false;
}
}
_dollyOut( dollyScale ) {
if ( this.object.isPerspectiveCamera || this.object.isOrthographicCamera ) {
this._scale /= dollyScale;
} else {
console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
this.enableZoom = false;
}
}
_dollyIn( dollyScale ) {
if ( this.object.isPerspectiveCamera || this.object.isOrthographicCamera ) {
this._scale *= dollyScale;
} else {
console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
this.enableZoom = false;
}
}
_updateZoomParameters( x, y ) {
if ( ! this.zoomToCursor ) {
return;
}
this._performCursorZoom = true;
const rect = this.domElement.getBoundingClientRect();
const dx = x - rect.left;
const dy = y - rect.top;
const w = rect.width;
const h = rect.height;
this._mouse.x = ( dx / w ) * 2 - 1;
this._mouse.y = - ( dy / h ) * 2 + 1;
this._dollyDirection.set( this._mouse.x, this._mouse.y, 1 ).unproject( this.object ).sub( this.object.position ).normalize();
}
_clampDistance( dist ) {
return Math.max( this.minDistance, Math.min( this.maxDistance, dist ) );
}
//
// event callbacks - update the object state
//
_handleMouseDownRotate( event ) {
this._rotateStart.set( event.clientX, event.clientY );
}
_handleMouseDownDolly( event ) {
this._updateZoomParameters( event.clientX, event.clientX );
this._dollyStart.set( event.clientX, event.clientY );
}
_handleMouseDownPan( event ) {
this._panStart.set( event.clientX, event.clientY );
}
_handleMouseMoveRotate( event ) {
this._rotateEnd.set( event.clientX, event.clientY );
this._rotateDelta.subVectors( this._rotateEnd, this._rotateStart ).multiplyScalar( this.rotateSpeed );
const element = this.domElement;
this._rotateLeft( _twoPI * this._rotateDelta.x / element.clientHeight ); // yes, height
this._rotateUp( _twoPI * this._rotateDelta.y / element.clientHeight );
this._rotateStart.copy( this._rotateEnd );
this.update();
}
_handleMouseMoveDolly( event ) {
this._dollyEnd.set( event.clientX, event.clientY );
this._dollyDelta.subVectors( this._dollyEnd, this._dollyStart );
if ( this._dollyDelta.y > 0 ) {
this._dollyOut( this._getZoomScale( this._dollyDelta.y ) );
} else if ( this._dollyDelta.y < 0 ) {
this._dollyIn( this._getZoomScale( this._dollyDelta.y ) );
}
this._dollyStart.copy( this._dollyEnd );
this.update();
}
_handleMouseMovePan( event ) {
this._panEnd.set( event.clientX, event.clientY );
this._panDelta.subVectors( this._panEnd, this._panStart ).multiplyScalar( this.panSpeed );
this._pan( this._panDelta.x, this._panDelta.y );
this._panStart.copy( this._panEnd );
this.update();
}
_handleMouseWheel( event ) {
this._updateZoomParameters( event.clientX, event.clientY );
if ( event.deltaY < 0 ) {
this._dollyIn( this._getZoomScale( event.deltaY ) );
} else if ( event.deltaY > 0 ) {
this._dollyOut( this._getZoomScale( event.deltaY ) );
}
this.update();
}
_handleKeyDown( event ) {
let needsUpdate = false;
switch ( event.code ) {
case this.keys.UP:
if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
if ( this.enableRotate ) {
this._rotateUp( _twoPI * this.keyRotateSpeed / this.domElement.clientHeight );
}
} else {
if ( this.enablePan ) {
this._pan( 0, this.keyPanSpeed );
}
}
needsUpdate = true;
break;
case this.keys.BOTTOM:
if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
if ( this.enableRotate ) {
this._rotateUp( - _twoPI * this.keyRotateSpeed / this.domElement.clientHeight );
}
} else {
if ( this.enablePan ) {
this._pan( 0, - this.keyPanSpeed );
}
}
needsUpdate = true;
break;
case this.keys.LEFT:
if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
if ( this.enableRotate ) {
this._rotateLeft( _twoPI * this.keyRotateSpeed / this.domElement.clientHeight );
}
} else {
if ( this.enablePan ) {
this._pan( this.keyPanSpeed, 0 );
}
}
needsUpdate = true;
break;
case this.keys.RIGHT:
if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
if ( this.enableRotate ) {
this._rotateLeft( - _twoPI * this.keyRotateSpeed / this.domElement.clientHeight );
}
} else {
if ( this.enablePan ) {
this._pan( - this.keyPanSpeed, 0 );
}
}
needsUpdate = true;
break;
}
if ( needsUpdate ) {
// prevent the browser from scrolling on cursor keys
event.preventDefault();
this.update();
}
}
_handleTouchStartRotate( event ) {
if ( this._pointers.length === 1 ) {
this._rotateStart.set( event.pageX, event.pageY );
} else {
const position = this._getSecondPointerPosition( event );
const x = 0.5 * ( event.pageX + position.x );
const y = 0.5 * ( event.pageY + position.y );
this._rotateStart.set( x, y );
}
}
_handleTouchStartPan( event ) {
if ( this._pointers.length === 1 ) {
this._panStart.set( event.pageX, event.pageY );
} else {
const position = this._getSecondPointerPosition( event );
const x = 0.5 * ( event.pageX + position.x );
const y = 0.5 * ( event.pageY + position.y );
this._panStart.set( x, y );
}
}
_handleTouchStartDolly( event ) {
const position = this._getSecondPointerPosition( event );
const dx = event.pageX - position.x;
const dy = event.pageY - position.y;
const distance = Math.sqrt( dx * dx + dy * dy );
this._dollyStart.set( 0, distance );
}
_handleTouchStartDollyPan( event ) {
if ( this.enableZoom ) this._handleTouchStartDolly( event );
if ( this.enablePan ) this._handleTouchStartPan( event );
}
_handleTouchStartDollyRotate( event ) {
if ( this.enableZoom ) this._handleTouchStartDolly( event );
if ( this.enableRotate ) this._handleTouchStartRotate( event );
}
_handleTouchMoveRotate( event ) {
if ( this._pointers.length == 1 ) {
this._rotateEnd.set( event.pageX, event.pageY );
} else {
const position = this._getSecondPointerPosition( event );
const x = 0.5 * ( event.pageX + position.x );
const y = 0.5 * ( event.pageY + position.y );
this._rotateEnd.set( x, y );
}
this._rotateDelta.subVectors( this._rotateEnd, this._rotateStart ).multiplyScalar( this.rotateSpeed );
const element = this.domElement;
this._rotateLeft( _twoPI * this._rotateDelta.x / element.clientHeight ); // yes, height
this._rotateUp( _twoPI * this._rotateDelta.y / element.clientHeight );
this._rotateStart.copy( this._rotateEnd );
}
_handleTouchMovePan( event ) {
if ( this._pointers.length === 1 ) {
this._panEnd.set( event.pageX, event.pageY );
} else {
const position = this._getSecondPointerPosition( event );
const x = 0.5 * ( event.pageX + position.x );
const y = 0.5 * ( event.pageY + position.y );
this._panEnd.set( x, y );
}
this._panDelta.subVectors( this._panEnd, this._panStart ).multiplyScalar( this.panSpeed );
this._pan( this._panDelta.x, this._panDelta.y );
this._panStart.copy( this._panEnd );
}
_handleTouchMoveDolly( event ) {
const position = this._getSecondPointerPosition( event );
const dx = event.pageX - position.x;
const dy = event.pageY - position.y;
const distance = Math.sqrt( dx * dx + dy * dy );
this._dollyEnd.set( 0, distance );
this._dollyDelta.set( 0, Math.pow( this._dollyEnd.y / this._dollyStart.y, this.zoomSpeed ) );
this._dollyOut( this._dollyDelta.y );
this._dollyStart.copy( this._dollyEnd );
const centerX = ( event.pageX + position.x ) * 0.5;
const centerY = ( event.pageY + position.y ) * 0.5;
this._updateZoomParameters( centerX, centerY );
}
_handleTouchMoveDollyPan( event ) {
if ( this.enableZoom ) this._handleTouchMoveDolly( event );
if ( this.enablePan ) this._handleTouchMovePan( event );
}
_handleTouchMoveDollyRotate( event ) {
if ( this.enableZoom ) this._handleTouchMoveDolly( event );
if ( this.enableRotate ) this._handleTouchMoveRotate( event );
}
// pointers
_addPointer( event ) {
this._pointers.push( event.pointerId );
}
_removePointer( event ) {
delete this._pointerPositions[ event.pointerId ];
for ( let i = 0; i < this._pointers.length; i ++ ) {
if ( this._pointers[ i ] == event.pointerId ) {
this._pointers.splice( i, 1 );
return;
}
}
}
_isTrackingPointer( event ) {
for ( let i = 0; i < this._pointers.length; i ++ ) {
if ( this._pointers[ i ] == event.pointerId ) return true;
}
return false;
}
_trackPointer( event ) {
let position = this._pointerPositions[ event.pointerId ];
if ( position === undefined ) {
position = new Vector2();
this._pointerPositions[ event.pointerId ] = position;
}
position.set( event.pageX, event.pageY );
}
_getSecondPointerPosition( event ) {
const pointerId = ( event.pointerId === this._pointers[ 0 ] ) ? this._pointers[ 1 ] : this._pointers[ 0 ];
return this._pointerPositions[ pointerId ];
}
//
_customWheelEvent( event ) {
const mode = event.deltaMode;
// minimal wheel event altered to meet delta-zoom demand
const newEvent = {
clientX: event.clientX,
clientY: event.clientY,
deltaY: event.deltaY,
};
switch ( mode ) {
case 1: // LINE_MODE
newEvent.deltaY *= 16;
break;
case 2: // PAGE_MODE
newEvent.deltaY *= 100;
break;
}
// detect if event was triggered by pinching
if ( event.ctrlKey && ! this._controlActive ) {
newEvent.deltaY *= 10;
}
return newEvent;
}
}
function onPointerDown( event ) {
if ( this.enabled === false ) return;
if ( this._pointers.length === 0 ) {
this.domElement.setPointerCapture( event.pointerId );
this.domElement.addEventListener( 'pointermove', this._onPointerMove );
this.domElement.addEventListener( 'pointerup', this._onPointerUp );
}
//
if ( this._isTrackingPointer( event ) ) return;
//
this._addPointer( event );
if ( event.pointerType === 'touch' ) {
this._onTouchStart( event );
} else {
this._onMouseDown( event );
}
}
function onPointerMove( event ) {
if ( this.enabled === false ) return;
if ( event.pointerType === 'touch' ) {
this._onTouchMove( event );
} else {
this._onMouseMove( event );
}
}
function onPointerUp( event ) {
this._removePointer( event );
switch ( this._pointers.length ) {
case 0:
this.domElement.releasePointerCapture( event.pointerId );
this.domElement.removeEventListener( 'pointermove', this._onPointerMove );
this.domElement.removeEventListener( 'pointerup', this._onPointerUp );
this.dispatchEvent( _endEvent );
this.state = _STATE.NONE;
break;
case 1:
const pointerId = this._pointers[ 0 ];
const position = this._pointerPositions[ pointerId ];
// minimal placeholder event - allows state correction on pointer-up
this._onTouchStart( { pointerId: pointerId, pageX: position.x, pageY: position.y } );
break;
}
}
function onMouseDown( event ) {
let mouseAction;
switch ( event.button ) {
case 0:
mouseAction = this.mouseButtons.LEFT;
break;
case 1:
mouseAction = this.mouseButtons.MIDDLE;
break;
case 2:
mouseAction = this.mouseButtons.RIGHT;
break;
default:
mouseAction = - 1;
}
switch ( mouseAction ) {
case MOUSE.DOLLY:
if ( this.enableZoom === false ) return;
this._handleMouseDownDolly( event );
this.state = _STATE.DOLLY;
break;
case MOUSE.ROTATE:
if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
if ( this.enablePan === false ) return;
this._handleMouseDownPan( event );
this.state = _STATE.PAN;
} else {
if ( this.enableRotate === false ) return;
this._handleMouseDownRotate( event );
this.state = _STATE.ROTATE;
}
break;
case MOUSE.PAN:
if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
if ( this.enableRotate === false ) return;
this._handleMouseDownRotate( event );
this.state = _STATE.ROTATE;
} else {
if ( this.enablePan === false ) return;
this._handleMouseDownPan( event );
this.state = _STATE.PAN;
}
break;
default:
this.state = _STATE.NONE;
}
if ( this.state !== _STATE.NONE ) {
this.dispatchEvent( _startEvent );
}
}
function onMouseMove( event ) {
switch ( this.state ) {
case _STATE.ROTATE:
if ( this.enableRotate === false ) return;
this._handleMouseMoveRotate( event );
break;
case _STATE.DOLLY:
if ( this.enableZoom === false ) return;
this._handleMouseMoveDolly( event );
break;
case _STATE.PAN:
if ( this.enablePan === false ) return;
this._handleMouseMovePan( event );
break;
}
}
function onMouseWheel( event ) {
if ( this.enabled === false || this.enableZoom === false || this.state !== _STATE.NONE ) return;
event.preventDefault();
this.dispatchEvent( _startEvent );
this._handleMouseWheel( this._customWheelEvent( event ) );
this.dispatchEvent( _endEvent );
}
function onKeyDown( event ) {
if ( this.enabled === false ) return;
this._handleKeyDown( event );
}
function onTouchStart( event ) {
this._trackPointer( event );
switch ( this._pointers.length ) {
case 1:
switch ( this.touches.ONE ) {
case TOUCH.ROTATE:
if ( this.enableRotate === false ) return;
this._handleTouchStartRotate( event );
this.state = _STATE.TOUCH_ROTATE;
break;
case TOUCH.PAN:
if ( this.enablePan === false ) return;
this._handleTouchStartPan( event );
this.state = _STATE.TOUCH_PAN;
break;
default:
this.state = _STATE.NONE;
}
break;
case 2:
switch ( this.touches.TWO ) {
case TOUCH.DOLLY_PAN:
if ( this.enableZoom === false && this.enablePan === false ) return;
this._handleTouchStartDollyPan( event );
this.state = _STATE.TOUCH_DOLLY_PAN;
break;
case TOUCH.DOLLY_ROTATE:
if ( this.enableZoom === false && this.enableRotate === false ) return;
this._handleTouchStartDollyRotate( event );
this.state = _STATE.TOUCH_DOLLY_ROTATE;
break;
default:
this.state = _STATE.NONE;
}
break;
default:
this.state = _STATE.NONE;
}
if ( this.state !== _STATE.NONE ) {
this.dispatchEvent( _startEvent );
}
}
function onTouchMove( event ) {
this._trackPointer( event );
switch ( this.state ) {
case _STATE.TOUCH_ROTATE:
if ( this.enableRotate === false ) return;
this._handleTouchMoveRotate( event );
this.update();
break;
case _STATE.TOUCH_PAN:
if ( this.enablePan === false ) return;
this._handleTouchMovePan( event );
this.update();
break;
case _STATE.TOUCH_DOLLY_PAN:
if ( this.enableZoom === false && this.enablePan === false ) return;
this._handleTouchMoveDollyPan( event );
this.update();
break;
case _STATE.TOUCH_DOLLY_ROTATE:
if ( this.enableZoom === false && this.enableRotate === false ) return;
this._handleTouchMoveDollyRotate( event );
this.update();
break;
default:
this.state = _STATE.NONE;
}
}
function onContextMenu( event ) {
if ( this.enabled === false ) return;
event.preventDefault();
}
function interceptControlDown( event ) {
if ( event.key === 'Control' ) {
this._controlActive = true;
const document = this.domElement.getRootNode(); // offscreen canvas compatibility
document.addEventListener( 'keyup', this._interceptControlUp, { passive: true, capture: true } );
}
}
function interceptControlUp( event ) {
if ( event.key === 'Control' ) {
this._controlActive = false;
const document = this.domElement.getRootNode(); // offscreen canvas compatibility
document.removeEventListener( 'keyup', this._interceptControlUp, { passive: true, capture: true } );
}
}
export { OrbitControls };
import {
BufferGeometry,
Float32BufferAttribute,
OrthographicCamera,
Mesh
} from '../three.module.js';
/**
* Abstract base class for all post processing passes.
*
* This module is only relevant for post processing with {@link WebGLRenderer}.
*
* @abstract
* @three_import import { Pass } from 'three/addons/postprocessing/Pass.js';
*/
class Pass {
/**
* Constructs a new pass.
*/
constructor() {
/**
* This flag can be used for type testing.
*
* @type {boolean}
* @readonly
* @default true
*/
this.isPass = true;
/**
* If set to `true`, the pass is processed by the composer.
*
* @type {boolean}
* @default true
*/
this.enabled = true;
/**
* If set to `true`, the pass indicates to swap read and write buffer after rendering.
*
* @type {boolean}
* @default true
*/
this.needsSwap = true;
/**
* If set to `true`, the pass clears its buffer before rendering
*
* @type {boolean}
* @default false
*/
this.clear = false;
/**
* If set to `true`, the result of the pass is rendered to screen. The last pass in the composers
* pass chain gets automatically rendered to screen, no matter how this property is configured.
*
* @type {boolean}
* @default false
*/
this.renderToScreen = false;
}
/**
* Sets the size of the pass.
*
* @abstract
* @param {number} width - The width to set.
* @param {number} height - The height to set.
*/
setSize( /* width, height */ ) {}
/**
* This method holds the render logic of a pass. It must be implemented in all derived classes.
*
* @abstract
* @param {WebGLRenderer} renderer - The renderer.
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
* destination for the pass.
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
* previous pass from this buffer.
* @param {number} deltaTime - The delta time in seconds.
* @param {boolean} maskActive - Whether masking is active or not.
*/
render( /* renderer, writeBuffer, readBuffer, deltaTime, maskActive */ ) {
console.error( 'THREE.Pass: .render() must be implemented in derived pass.' );
}
/**
* Frees the GPU-related resources allocated by this instance. Call this
* method whenever the pass is no longer used in your app.
*
* @abstract
*/
dispose() {}
}
// Helper for passes that need to fill the viewport with a single quad.
const _camera = new OrthographicCamera( - 1, 1, 1, - 1, 0, 1 );
// https://github.com/mrdoob/three.js/pull/21358
class FullscreenTriangleGeometry extends BufferGeometry {
constructor() {
super();
this.setAttribute( 'position', new Float32BufferAttribute( [ - 1, 3, 0, - 1, - 1, 0, 3, - 1, 0 ], 3 ) );
this.setAttribute( 'uv', new Float32BufferAttribute( [ 0, 2, 0, 0, 2, 0 ], 2 ) );
}
}
const _geometry = new FullscreenTriangleGeometry();
/**
* This module is a helper for passes which need to render a full
* screen effect which is quite common in context of post processing.
*
* The intended usage is to reuse a single full screen quad for rendering
* subsequent passes by just reassigning the `material` reference.
*
* This module can only be used with {@link WebGLRenderer}.
*
* @augments Mesh
* @three_import import { FullScreenQuad } from 'three/addons/postprocessing/Pass.js';
*/
class FullScreenQuad {
/**
* Constructs a new full screen quad.
*
* @param {?Material} material - The material to render te full screen quad with.
*/
constructor( material ) {
this._mesh = new Mesh( _geometry, material );
}
/**
* Frees the GPU-related resources allocated by this instance. Call this
* method whenever the instance is no longer used in your app.
*/
dispose() {
this._mesh.geometry.dispose();
}
/**
* Renders the full screen quad.
*
* @param {WebGLRenderer} renderer - The renderer.
*/
render( renderer ) {
renderer.render( this._mesh, _camera );
}
/**
* The quad's material.
*
* @type {?Material}
*/
get material() {
return this._mesh.material;
}
set material( value ) {
this._mesh.material = value;
}
}
export { Pass, FullScreenQuad };
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
File added
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