Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
3
3DMobileRender
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xhw
3DMobileRender
Commits
afc33b83
Commit
afc33b83
authored
Jul 22, 2026
by
xhw
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
loadNewModel
parent
9dff46d5
Show whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
157 additions
and
1210 deletions
+157
-1210
demo.html
+91
-219
index.html
+28
-0
index4.html
+38
-991
No files found.
demo.html
View file @
afc33b83
<!DOCTYPE html>
<!DOCTYPE html>
<html
lang=
"zh-CN"
>
<html
lang=
"zh-CN"
>
<head>
<head>
<meta
charset=
"UTF-8"
/>
<meta
charset=
"UTF-8"
/>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
/>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
/>
<title>
3D高斯泼溅 -
视角探索
</title>
<title>
3D高斯泼溅 -
SparkSplatViewer
</title>
<style>
<style>
*
{
margin
:
0
;
padding
:
0
;
}
*
{
margin
:
0
;
padding
:
0
;
box-sizing
:
border-box
;
}
body
{
overflow
:
hidden
;
background
:
#1a1a2e
;
font-family
:
'Segoe UI'
,
Arial
,
sans-serif
;
}
body
{
canvas
{
display
:
block
;
}
overflow
:
hidden
;
background
:
#1a1a2e
;
font-family
:
'Segoe UI'
,
Arial
,
sans-serif
;
width
:
100vw
;
height
:
100vh
;
}
#viewer-container
{
width
:
100%
;
height
:
100%
;
}
</style>
</style>
<base
target=
"_blank"
>
<base
target=
"_blank"
>
</head>
</head>
<body>
<body>
<script
type=
"module"
>
<div
id=
"viewer-container"
></div>
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
();
<script
type=
"module"
>
import
{
SparkSplatViewer
}
from
"./SparkSplatViewer.js"
;
// 保存初始视角
const
HOME_POS
=
perspCamera
.
position
.
clone
();
// 初始化组件
const
HOME_TARGET
=
controls
.
target
.
clone
();
const
container
=
document
.
getElementById
(
"viewer-container"
);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const
viewer
=
new
SparkSplatViewer
(
container
,
{
// 4. Spark 渲染器
// 场景配置
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
backgroundColor
:
0x1a1a2e
,
const
spark
=
new
SparkRenderer
({
renderer
,
// 相机配置
maxStdDev
:
Math
.
sqrt
(
5
),
// 限制高斯核范围,提升性能
fov
:
45
,
sortRadial
:
true
,
// 径向排序,快速旋转时避免黑边(默认就是 true)
fovMobile
:
20
,
enableLod
:
true
,
// 启用 LoD(大场景必需)
initialPosition
:
{
x
:
0
,
y
:
0
,
z
:
5
},
lodSplatScale
:
1.0
// 细节倍率
});
// 控制器配置
scene
.
add
(
spark
);
enablePan
:
false
,
minPolarAngle
:
Math
.
PI
/
2
-
Math
.
PI
/
6
,
const
splatMesh
=
new
SplatMesh
({
maxPolarAngle
:
Math
.
PI
/
2
+
Math
.
PI
/
6
,
url
:
'./Advance.spz'
,
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
)
=>
{
onLoad
:
(
mesh
)
=>
{
console
.
log
(
"模型加载完成"
);
console
.
log
(
"默认模型加载完成"
,
mesh
);
fitCameraToModel
();
},
const
state
=
getDeviceType
();
onError
:
(
err
)
=>
{
state
===
"desktop"
?
animateZoomTo
(
2.2
,
500
)
:
animateZoomTo
(
0.55
,
500
);
console
.
error
(
"模型加载失败"
,
err
);
// 加载完成后,把相机从当前位置往模型方向(-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
;
window
.
viewer
=
viewer
;
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
)
{
* @param {string} url - 模型文件路径 (.spz, .ply, .splat, .ksplat, .sog, .rad 等)
box
.
expandByObject
(
child
);
* @param {Object} options - 可选配置
}
*/
window
.
loadNewModel
=
(
url
,
options
=
{})
=>
{
viewer
.
loadModel
(
url
,
{
autoFit
:
options
.
autoFit
!==
false
,
// 默认自动适配相机
zoomDuration
:
options
.
zoomDuration
??
500
,
// 缩放动画时长
desktopZoom
:
options
.
desktopZoom
??
2.2
,
// 桌面端缩放
mobileZoom
:
options
.
mobileZoom
??
0.55
,
// 移动端缩放
tabletZoom
:
options
.
tabletZoom
??
1.0
,
// 平板端缩放
onComplete
:
options
.
onComplete
,
// 加载完成回调
onError
:
options
.
onError
,
// 加载失败回调
onProgress
:
options
.
onProgress
,
// 进度回调
});
});
if
(
box
.
isEmpty
())
{
};
box
.
setFromObject
(
splatMesh
);
}
return
box
;
}
function
fitPerspectiveToModel
()
{
const
box
=
getModelBounds
();
console
.
log
(
"模型包围盒:"
,
box
);
if
(
!
box
||
box
.
isEmpty
())
{
console
.
warn
(
"无法计算包围盒,使用默认范围"
);
const
size
=
0.3
;
const
dist
=
size
/
(
2
*
Math
.
tan
((
perspCamera
.
fov
*
Math
.
PI
)
/
360
));
perspCamera
.
position
.
set
(
0
,
0
,
dist
*
3
);
controls
.
target
.
set
(
0
,
0
,
0
);
controls
.
update
();
return
;
}
const
center
=
new
THREE
.
Vector3
();
box
.
getCenter
(
center
);
const
size
=
new
THREE
.
Vector3
();
box
.
getSize
(
size
);
const
maxDim
=
Math
.
max
(
size
.
x
,
size
.
y
,
size
.
z
);
controls
.
target
.
copy
(
center
);
const
fovRad
=
(
perspCamera
.
fov
*
Math
.
PI
)
/
180
;
const
dist
=
maxDim
/
(
2
*
Math
.
tan
(
fovRad
/
2
))
*
0.25
;
// ★ 修复:从 Z 轴方向观察(标准前视图)
// 保持相机在 Z 轴方向,Y 轴向上
const
newPos
=
center
.
clone
();
newPos
.
z
+=
Math
.
max
(
dist
,
0.1
);
// 沿 +Z 方向后退
// 也可以根据需要从其他角度观察:
// newPos.y += dist; // 从上方俯视
// newPos.x += dist; // 从侧面观察
perspCamera
.
position
.
copy
(
newPos
);
// ★ 修复:确保相机看向模型中心
perspCamera
.
lookAt
(
center
);
controls
.
update
();
controls
.
maxDistance
=
dist
*
3
;
controls
.
minDistance
=
maxDim
*
0.1
;
HOME_POS
.
copy
(
perspCamera
.
position
);
HOME_TARGET
.
copy
(
controls
.
target
);
}
function
fitCameraToModel
()
{
fitPerspectiveToModel
();
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 7. 渲染循环
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
renderer
.
setAnimationLoop
(()
=>
{
controls
.
update
();
renderer
.
render
(
scene
,
camera
);
});
// 暴露到全局供调试
window
.
__scene
=
scene
;
window
.
__camera
=
camera
;
window
.
__controls
=
controls
;
window
.
__splat
=
splatMesh
;
// 平滑过渡 zoom
function
animateZoomTo
(
targetZoom
,
duration
=
500
)
{
if
(
zoomAnimId
)
cancelAnimationFrame
(
zoomAnimId
);
const
startZoom
=
camera
.
zoom
;
const
startTime
=
performance
.
now
();
function
step
(
time
)
{
const
t
=
Math
.
min
((
time
-
startTime
)
/
duration
,
1
);
const
ease
=
t
<
0.5
?
4
*
t
*
t
*
t
:
1
-
Math
.
pow
(
-
2
*
t
+
2
,
3
)
/
2
;
camera
.
zoom
=
startZoom
+
(
targetZoom
-
startZoom
)
*
ease
;
camera
.
updateProjectionMatrix
();
if
(
t
<
1
)
{
zoomAnimId
=
requestAnimationFrame
(
step
);
}
else
{
zoomAnimId
=
null
;
updateInfo
();
}
}
zoomAnimId
=
requestAnimationFrame
(
step
);
}
function
updateInfo
()
{
const
p
=
camera
.
position
;
const
t
=
controls
.
target
;
const
r
=
camera
.
rotation
;
console
.
log
(
`相机位置: (
${
p
.
x
.
toFixed
(
2
)}
,
${
p
.
y
.
toFixed
(
2
)}
,
${
p
.
z
.
toFixed
(
2
)}
)`
);
console
.
log
(
`旋转中心: (
${
t
.
x
.
toFixed
(
2
)}
,
${
t
.
y
.
toFixed
(
2
)}
,
${
t
.
z
.
toFixed
(
2
)}
)`
);
}
function
getDeviceType
()
{
// 使用示例:
const
ua
=
navigator
.
userAgent
;
// loadNewModel("./new-model.spz");
const
isTablet
=
/iPad|Android
(?!
.*Mobile
)
/i
.
test
(
ua
)
||
// loadNewModel("./new-model.spz", { autoFit: true, desktopZoom: 3.0 });
(
/Android/i
.
test
(
ua
)
&&
window
.
innerWidth
>
768
);
if
(
/Mobi|Android|iPhone/i
.
test
(
ua
)
&&
!
isTablet
)
return
'mobile'
;
// 其他可用方法:
if
(
isTablet
)
return
'tablet'
;
// viewer.resetCamera(); // 重置相机
return
'desktop'
;
// viewer.fitCameraToModel(); // 适配相机到模型
}
// viewer.getCameraInfo(); // 获取相机信息
// viewer.animateZoomTo(2.0, 800); // 平滑缩放到指定值
// viewer.destroy(); // 销毁组件
window
.
addEventListener
(
"resize"
,
()
=>
{
// 获取模型包围盒
const
w
=
window
.
innerWidth
;
// const bounds = viewer.getModelBounds();
const
h
=
window
.
innerHeight
;
camera
.
aspect
=
w
/
h
;
camera
.
updateProjectionMatrix
();
renderer
.
setSize
(
w
,
h
);
});
</script>
</script>
</body>
</body>
</html>
</html>
\ No newline at end of file
index.html
View file @
afc33b83
...
@@ -91,6 +91,34 @@
...
@@ -91,6 +91,34 @@
onComplete
:
options
.
onComplete
,
// 加载完成回调
onComplete
:
options
.
onComplete
,
// 加载完成回调
onError
:
options
.
onError
,
// 加载失败回调
onError
:
options
.
onError
,
// 加载失败回调
onProgress
:
options
.
onProgress
,
// 进度回调
onProgress
:
options
.
onProgress
,
// 进度回调
},()
=>
{});
};
window
.
loadNewModel
=
(
url
,
options
=
{},
callback
=
null
)
=>
{
// 简写形式: loadNewModel(url, (mesh) => { ... })
if
(
typeof
options
===
"function"
)
{
callback
=
options
;
options
=
{};
}
return
new
Promise
((
resolve
,
reject
)
=>
{
viewer
.
loadModel
(
url
,
{
autoFit
:
options
.
autoFit
!==
false
,
zoomDuration
:
options
.
zoomDuration
??
500
,
desktopZoom
:
options
.
desktopZoom
??
2.2
,
mobileZoom
:
options
.
mobileZoom
??
0.55
,
tabletZoom
:
options
.
tabletZoom
??
1.0
,
onProgress
:
options
.
onProgress
,
onComplete
:
(
mesh
)
=>
{
if
(
options
.
onComplete
)
options
.
onComplete
(
mesh
);
if
(
callback
)
callback
(
mesh
);
resolve
(
mesh
);
},
onError
:
(
err
)
=>
{
if
(
options
.
onError
)
options
.
onError
(
err
);
reject
(
err
);
},
});
});
});
};
};
...
...
index4.html
View file @
afc33b83
<!DOCTYPE html>
<!DOCTYPE html>
<html
lang=
"zh-CN"
>
<html>
<head>
<head>
<meta
charset=
"UTF-8"
/>
<meta
charset=
"utf-8"
>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
/>
<style>body
{
margin
:
0
;
overflow
:
hidden
;
}
</style>
<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"
>
<script
type=
"importmap"
>
{
{
"imports"
:
{
"playcanvas"
:
"https://cdn.jsdelivr.net/npm/playcanvas/+esm"
}
}
"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>
</script>
<base
target=
"_blank"
>
</head>
</head>
<body>
<body>
<script
type=
"module"
>
<!-- 控制面板 -->
import
*
as
pc
from
'playcanvas'
;
<div
class=
"panel"
id=
"panel"
>
<span
class=
"label"
>
📐 视角
</span>
const
canvas
=
document
.
createElement
(
'canvas'
);
<button
data-view=
"front"
>
正面
</button>
document
.
body
.
appendChild
(
canvas
);
<button
data-view=
"back"
>
背面
</button>
<button
data-view=
"left"
>
左侧
</button>
const
app
=
new
pc
.
Application
(
canvas
,
{
<button
data-view=
"right"
>
右侧
</button>
graphicsDeviceOptions
:
{
antialias
:
false
}
<button
data-view=
"top"
>
俯视
</button>
});
<button
data-view=
"bottom"
>
仰视
</button>
app
.
setCanvasFillMode
(
pc
.
FILLMODE_FILL_WINDOW
);
<button
data-view=
"home"
>
复位
</button>
app
.
setCanvasResolution
(
pc
.
RESOLUTION_AUTO
);
<div
class=
"sep"
></div>
app
.
start
();
<button
id=
"btnAutoRotate"
>
🔄 自转
</button>
window
.
addEventListener
(
'resize'
,
()
=>
app
.
resizeCanvas
());
<button
id=
"btnFit"
>
📦 适配
</button>
<button
id=
"btnProjection"
title=
"切换透视/正交投影"
>
🔲 正交
</button>
// 相机(这个场景的包围盒约 ±200 单位,像厘米尺度,所以放远一点)
<button
id=
"btnAxes"
>
🎯 坐标轴
</button>
const
camera
=
new
pc
.
Entity
(
'camera'
);
<button
id=
"btnSave"
>
💾 保存视角
</button>
camera
.
addComponent
(
'camera'
,
{
nearClip
:
1
,
farClip
:
2000
});
<button
id=
"btnLoad"
>
📂 恢复视角
</button>
camera
.
setPosition
(
150
,
120
,
250
);
<button
id=
"btnProjection"
title=
"切换透视/正交投影"
>
🔲 正交
</button>
camera
.
lookAt
(
0
,
0
,
0
);
<!-- ▼ 新增 ▼ -->
app
.
root
.
addChild
(
camera
);
<button
id=
"btnAutoCrop"
title=
"自动放大裁剪,去除画面四周留白"
>
✂️ 去留白
</button>
</div>
// 关键:asset 直接指向 lod-meta.json,gsplat 组件开 unified
const
asset
=
new
pc
.
Asset
(
'scene'
,
'gsplat'
,
{
<!-- 信息 -->
url
:
'./les-tanins/lod-meta.json'
<div
class=
"info"
id=
"info"
>
});
<div>
相机:
<span
id=
"camPos"
class=
"highlight"
>
—
</span></div>
app
.
assets
.
add
(
asset
);
<div>
目标:
<span
id=
"camTarget"
class=
"highlight"
>
—
</span></div>
app
.
assets
.
load
(
asset
);
<div>
投影:
<span
id=
"camProj"
class=
"highlight"
>
透视
</span></div>
asset
.
ready
(()
=>
{
<div
class=
"rot-row"
>
旋转:
<span
id=
"camRot"
class=
"highlight"
>
—
</span></div>
const
splat
=
new
pc
.
Entity
(
'splat'
);
<div
style=
"margin-top:4px; opacity:0.5; font-size:11px;"
>
splat
.
addComponent
(
'gsplat'
,
{
asset
,
unified
:
true
});
// unified = 流式 LOD 渲染
💡 控制台输入
<span
style=
"color:#ffd700;"
>
getView()
</span>
获取完整视角信息
app
.
root
.
addChild
(
splat
);
</div>
});
</div>
</script>
<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>
</body>
</html>
</html>
\ No newline at end of file
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment