fix: start world view inside scene
This commit is contained in:
parent
94478e469f
commit
b6b14d739f
@ -6,11 +6,6 @@ import { Canvas, type ThreeEvent, useFrame, useThree } from "@react-three/fiber"
|
||||
import { SparkRenderer, SplatMesh as SparkSplatMesh } from "@sparkjsdev/spark";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Box,
|
||||
Crosshair,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
@ -206,6 +201,73 @@ function getSemanticFacts(metadata?: Record<string, unknown> | null) {
|
||||
return facts;
|
||||
}
|
||||
|
||||
function positionCameraInsideWorld({
|
||||
camera,
|
||||
controls,
|
||||
splatMesh,
|
||||
groundPlaneOffset,
|
||||
}: {
|
||||
camera: THREE.Camera;
|
||||
controls: { target?: THREE.Vector3; update?: () => void } | null;
|
||||
splatMesh: SparkSplatMesh;
|
||||
groundPlaneOffset: number | null;
|
||||
}) {
|
||||
if (!(camera instanceof THREE.PerspectiveCamera)) return;
|
||||
|
||||
splatMesh.updateMatrixWorld(true);
|
||||
const worldBounds = splatMesh.getBoundingBox(true).clone().applyMatrix4(splatMesh.matrixWorld);
|
||||
if (worldBounds.isEmpty()) return;
|
||||
|
||||
const center = worldBounds.getCenter(new THREE.Vector3());
|
||||
const size = worldBounds.getSize(new THREE.Vector3());
|
||||
const horizontalAxis =
|
||||
size.z >= size.x ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(1, 0, 0);
|
||||
const majorSpan = Math.max(horizontalAxis.z === 1 ? size.z : size.x, 1.4);
|
||||
const marginX = Math.max(size.x * 0.12, 0.35);
|
||||
const marginZ = Math.max(size.z * 0.12, 0.35);
|
||||
const eyeY =
|
||||
groundPlaneOffset !== null
|
||||
? groundPlaneOffset + 1.58
|
||||
: center.y - size.y * 0.08 + Math.max(size.y * 0.22, 0.9);
|
||||
const anchor = new THREE.Vector3(center.x, eyeY, center.z);
|
||||
const cameraPosition = anchor
|
||||
.clone()
|
||||
.addScaledVector(horizontalAxis, Math.max(majorSpan * 0.12, 0.65));
|
||||
const targetPosition = anchor
|
||||
.clone()
|
||||
.addScaledVector(horizontalAxis, -Math.max(majorSpan * 0.2, 1.1));
|
||||
|
||||
cameraPosition.x = THREE.MathUtils.clamp(
|
||||
cameraPosition.x,
|
||||
worldBounds.min.x + marginX,
|
||||
worldBounds.max.x - marginX
|
||||
);
|
||||
cameraPosition.z = THREE.MathUtils.clamp(
|
||||
cameraPosition.z,
|
||||
worldBounds.min.z + marginZ,
|
||||
worldBounds.max.z - marginZ
|
||||
);
|
||||
targetPosition.x = THREE.MathUtils.clamp(
|
||||
targetPosition.x,
|
||||
worldBounds.min.x + marginX * 0.5,
|
||||
worldBounds.max.x - marginX * 0.5
|
||||
);
|
||||
targetPosition.z = THREE.MathUtils.clamp(
|
||||
targetPosition.z,
|
||||
worldBounds.min.z + marginZ * 0.5,
|
||||
worldBounds.max.z - marginZ * 0.5
|
||||
);
|
||||
|
||||
camera.position.copy(cameraPosition);
|
||||
camera.lookAt(targetPosition);
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
if (controls?.target) {
|
||||
controls.target.copy(targetPosition);
|
||||
controls.update?.();
|
||||
}
|
||||
}
|
||||
|
||||
function WorldEmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
@ -291,87 +353,13 @@ function createMovementState(): MovementState {
|
||||
return { ...EMPTY_MOVEMENT_STATE };
|
||||
}
|
||||
|
||||
function DirectionPad({
|
||||
onCommandChange,
|
||||
}: {
|
||||
onCommandChange: (command: MovementCommand, active: boolean) => void;
|
||||
}) {
|
||||
const pressHandlers = (command: MovementCommand) => ({
|
||||
onMouseDown: () => onCommandChange(command, true),
|
||||
onMouseUp: () => onCommandChange(command, false),
|
||||
onMouseLeave: () => onCommandChange(command, false),
|
||||
onTouchStart: () => onCommandChange(command, true),
|
||||
onTouchEnd: () => onCommandChange(command, false),
|
||||
onTouchCancel: () => onCommandChange(command, false),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto rounded-2xl border border-white/10 bg-black/55 p-2 shadow-2xl backdrop-blur-sm">
|
||||
<div className="mb-2 text-center text-[10px] text-zinc-400">方向移动</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div />
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-zinc-100 transition hover:bg-white/10"
|
||||
type="button"
|
||||
{...pressHandlers("forward")}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-10 min-w-[3.25rem] items-center justify-center rounded-xl border border-white/10 bg-white/5 px-2 text-xs text-zinc-100 transition hover:bg-white/10"
|
||||
type="button"
|
||||
{...pressHandlers("up")}
|
||||
>
|
||||
上
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-zinc-100 transition hover:bg-white/10"
|
||||
type="button"
|
||||
{...pressHandlers("left")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex h-10 items-center justify-center rounded-xl border border-dashed border-white/10 bg-white/[0.03] px-2 text-[10px] text-zinc-500">
|
||||
前后左右
|
||||
</div>
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-zinc-100 transition hover:bg-white/10"
|
||||
type="button"
|
||||
{...pressHandlers("right")}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div />
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-zinc-100 transition hover:bg-white/10"
|
||||
type="button"
|
||||
{...pressHandlers("backward")}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-10 min-w-[3.25rem] items-center justify-center rounded-xl border border-white/10 bg-white/5 px-2 text-xs text-zinc-100 transition hover:bg-white/10"
|
||||
type="button"
|
||||
{...pressHandlers("down")}
|
||||
>
|
||||
下
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CameraMovementControls({
|
||||
enabled,
|
||||
firstPerson,
|
||||
virtualMovement,
|
||||
orbitControlsRef,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
firstPerson: boolean;
|
||||
virtualMovement: MovementState;
|
||||
orbitControlsRef: React.MutableRefObject<any>;
|
||||
}) {
|
||||
const { camera } = useThree();
|
||||
@ -427,8 +415,7 @@ function CameraMovementControls({
|
||||
if (!enabled) return;
|
||||
|
||||
const pressed = pressedKeysRef.current;
|
||||
const hasMovement =
|
||||
Object.values(pressed).some(Boolean) || Object.values(virtualMovement).some(Boolean);
|
||||
const hasMovement = Object.values(pressed).some(Boolean);
|
||||
if (!hasMovement) return;
|
||||
|
||||
const moveSpeed = 3.8;
|
||||
@ -441,22 +428,22 @@ function CameraMovementControls({
|
||||
|
||||
moveRef.current.set(0, 0, 0);
|
||||
|
||||
if (pressed.forward || virtualMovement.forward) {
|
||||
if (pressed.forward) {
|
||||
moveRef.current.add(directionRef.current);
|
||||
}
|
||||
if (pressed.backward || virtualMovement.backward) {
|
||||
if (pressed.backward) {
|
||||
moveRef.current.addScaledVector(directionRef.current, -1);
|
||||
}
|
||||
if (pressed.left || virtualMovement.left) {
|
||||
if (pressed.left) {
|
||||
moveRef.current.addScaledVector(rightRef.current, -1);
|
||||
}
|
||||
if (pressed.right || virtualMovement.right) {
|
||||
if (pressed.right) {
|
||||
moveRef.current.add(rightRef.current);
|
||||
}
|
||||
if (pressed.up || virtualMovement.up) {
|
||||
if (pressed.up) {
|
||||
moveRef.current.add(upRef.current);
|
||||
}
|
||||
if (pressed.down || virtualMovement.down) {
|
||||
if (pressed.down) {
|
||||
moveRef.current.addScaledVector(upRef.current, -1);
|
||||
}
|
||||
|
||||
@ -523,10 +510,10 @@ function WorldSplatScene({
|
||||
semanticsMetadata?: Record<string, unknown> | null;
|
||||
onLoadStateChange?: (state: "ready" | "error") => void;
|
||||
}) {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const renderer = useThree((state) => state.gl);
|
||||
const orbitControlsRef = useRef<any>(null);
|
||||
const [virtualMovement, setVirtualMovement] =
|
||||
useState<MovementState>(createMovementState);
|
||||
const hasAppliedInitialViewRef = useRef(false);
|
||||
const semanticFacts = useMemo(
|
||||
() => getSemanticFacts(semanticsMetadata),
|
||||
[semanticsMetadata]
|
||||
@ -543,6 +530,10 @@ function WorldSplatScene({
|
||||
[renderer]
|
||||
);
|
||||
const splatMesh = useMemo(() => new SparkSplatMesh({ url: splatUrl }), [splatUrl]);
|
||||
const groundPlaneOffset =
|
||||
typeof semanticsMetadata?.ground_plane_offset === "number"
|
||||
? -semanticsMetadata.ground_plane_offset
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
sparkRenderer.frustumCulled = false;
|
||||
@ -561,6 +552,7 @@ function WorldSplatScene({
|
||||
}, [revealPhase]);
|
||||
|
||||
useEffect(() => {
|
||||
hasAppliedInitialViewRef.current = false;
|
||||
splatMesh.frustumCulled = false;
|
||||
splatMesh.rotation.x = Math.PI;
|
||||
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE);
|
||||
@ -569,7 +561,17 @@ function WorldSplatScene({
|
||||
splatMesh.initialized
|
||||
.then(() => {
|
||||
if (cancelled) return;
|
||||
onLoadStateChange?.("ready");
|
||||
window.requestAnimationFrame(() => {
|
||||
if (cancelled || hasAppliedInitialViewRef.current) return;
|
||||
positionCameraInsideWorld({
|
||||
camera,
|
||||
controls: orbitControlsRef.current,
|
||||
groundPlaneOffset,
|
||||
splatMesh,
|
||||
});
|
||||
hasAppliedInitialViewRef.current = true;
|
||||
onLoadStateChange?.("ready");
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) onLoadStateChange?.("error");
|
||||
@ -578,7 +580,7 @@ function WorldSplatScene({
|
||||
cancelled = true;
|
||||
splatMesh.dispose();
|
||||
};
|
||||
}, [onLoadStateChange, sparkRenderer, splatMesh]);
|
||||
}, [camera, groundPlaneOffset, onLoadStateChange, sparkRenderer, splatMesh]);
|
||||
|
||||
useFrame(() => {
|
||||
const phase = revealPhaseRef.current;
|
||||
@ -624,21 +626,6 @@ function WorldSplatScene({
|
||||
sparkRenderer.needsUpdate = true;
|
||||
});
|
||||
|
||||
const groundPlaneOffset =
|
||||
typeof semanticsMetadata?.ground_plane_offset === "number"
|
||||
? -semanticsMetadata.ground_plane_offset
|
||||
: null;
|
||||
|
||||
const handleDirectionChange = (command: MovementCommand, active: boolean) => {
|
||||
setVirtualMovement((prev) => {
|
||||
if (prev[command] === active) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[command]: active,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: ThreeEvent<PointerEvent>) => {
|
||||
if (interactionMode !== "hotspot") return;
|
||||
event.stopPropagation();
|
||||
@ -675,11 +662,12 @@ function WorldSplatScene({
|
||||
enabled={interactionMode === "navigate"}
|
||||
firstPerson={firstPerson}
|
||||
orbitControlsRef={orbitControlsRef}
|
||||
virtualMovement={virtualMovement}
|
||||
/>
|
||||
{!firstPerson ? (
|
||||
<OrbitControls
|
||||
autoRotate={false}
|
||||
dampingFactor={0}
|
||||
enableDamping={false}
|
||||
enablePan
|
||||
enableRotate
|
||||
enableZoom
|
||||
@ -690,6 +678,8 @@ function WorldSplatScene({
|
||||
/>
|
||||
) : (
|
||||
<OrbitControls
|
||||
dampingFactor={0}
|
||||
enableDamping={false}
|
||||
enablePan={false}
|
||||
enableRotate={false}
|
||||
enableZoom={false}
|
||||
@ -719,23 +709,13 @@ function WorldSplatScene({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-[11px] text-zinc-300 backdrop-blur-sm">
|
||||
{firstPerson
|
||||
? "方向键 / WASD 前后左右 · Q/E 或 PgUp/PgDn 上下 · 鼠标观察 · Esc 退出"
|
||||
: "拖拽旋转 · 滚轮缩放 · 方向键 / 按钮可做六向移动"}
|
||||
{(firstPerson || interactionMode === "hotspot") && (
|
||||
<div className="flex items-end justify-end">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-white/10 bg-black/55 text-zinc-100 shadow-xl backdrop-blur-sm">
|
||||
<Crosshair className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end gap-3">
|
||||
{interactionMode === "navigate" ? (
|
||||
<DirectionPad onCommandChange={handleDirectionChange} />
|
||||
) : null}
|
||||
{(firstPerson || interactionMode === "hotspot") && (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-white/10 bg-black/55 text-zinc-100 shadow-xl backdrop-blur-sm">
|
||||
<Crosshair className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Html>
|
||||
</>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user