1732 lines
53 KiB
TypeScript
1732 lines
53 KiB
TypeScript
"use client";
|
||
|
||
/* eslint-disable @next/next/no-img-element */
|
||
import { Html, OrbitControls } from "@react-three/drei";
|
||
import { Canvas, type ThreeEvent, useFrame, useThree } from "@react-three/fiber";
|
||
import { SparkRenderer, SplatFileType, SplatMesh as SparkSplatMesh } from "@sparkjsdev/spark";
|
||
import {
|
||
AlertCircle,
|
||
Crosshair,
|
||
Globe,
|
||
ImageIcon,
|
||
Loader2,
|
||
Sparkles,
|
||
} from "lucide-react";
|
||
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||
import * as THREE from "three";
|
||
import {
|
||
type Model3DLayer,
|
||
type Model3DStats,
|
||
Model3DViewer,
|
||
type Model3DViewerSettings,
|
||
} from "@/components/canvas/model3d/Model3DViewer";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
|
||
import { cn } from "@/lib/utils";
|
||
import type { World3DHotspot, World3DViewMode } from "@/types/canvas";
|
||
|
||
type World3DPickResult = {
|
||
point: [number, number, number];
|
||
};
|
||
|
||
type MovementCommand =
|
||
| "forward"
|
||
| "backward"
|
||
| "left"
|
||
| "right"
|
||
| "up"
|
||
| "down";
|
||
|
||
type MovementState = Record<MovementCommand, boolean>;
|
||
type SplatLoadingPhase = "loading" | "revealing" | "hidden";
|
||
type SplatParticleCloudData = {
|
||
count: number;
|
||
colors: Float32Array;
|
||
positions: Float32Array;
|
||
seeds: Float32Array;
|
||
};
|
||
type SparkRevealProfile = {
|
||
blurAmount: number;
|
||
preBlurAmount: number;
|
||
minAlpha: number;
|
||
maxStdDev: number;
|
||
focalAdjustment: number;
|
||
falloff: number;
|
||
opacity: number;
|
||
};
|
||
|
||
type WorldCameraPose = {
|
||
cameraPosition: THREE.Vector3;
|
||
targetPosition: THREE.Vector3;
|
||
};
|
||
|
||
type World3DViewerProps = {
|
||
mode: World3DViewMode;
|
||
worldUrl?: string | null;
|
||
imageUrl?: string | null;
|
||
meshUrl?: string | null;
|
||
splatUrl?: string | null;
|
||
previewImageUrl?: string | null;
|
||
emptyStateTitle?: string;
|
||
minimalFrame?: boolean;
|
||
meshSettings?: Model3DViewerSettings;
|
||
meshLayerVisibility?: Record<string, boolean>;
|
||
onMeshLayersChange?: (layers: Model3DLayer[]) => void;
|
||
onMeshStatsChange?: (stats: Model3DStats) => void;
|
||
firstPerson?: boolean;
|
||
hotspots?: World3DHotspot[];
|
||
interactionMode?: "navigate" | "hotspot";
|
||
onPick?: (pick: World3DPickResult) => void;
|
||
onHotspotSelect?: (hotspotId: string) => void;
|
||
semanticsMetadata?: Record<string, unknown> | null;
|
||
className?: string;
|
||
resetViewToken?: number;
|
||
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
||
showMovementControls?: boolean;
|
||
};
|
||
|
||
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
||
cameraPreset: "iso",
|
||
envPreset: "city",
|
||
lightIntensity: 1,
|
||
autoRotate: false,
|
||
showGrid: true,
|
||
wireframe: false,
|
||
};
|
||
|
||
const EMPTY_MOVEMENT_STATE: MovementState = {
|
||
forward: false,
|
||
backward: false,
|
||
left: false,
|
||
right: false,
|
||
up: false,
|
||
down: false,
|
||
};
|
||
|
||
const SPLAT_LOADING_PROFILE: SparkRevealProfile = {
|
||
blurAmount: 1.15,
|
||
preBlurAmount: 0.34,
|
||
minAlpha: 0.1,
|
||
maxStdDev: Math.sqrt(10.5),
|
||
focalAdjustment: 0.82,
|
||
falloff: 0.72,
|
||
opacity: 0,
|
||
};
|
||
|
||
const SPLAT_PARTICLE_PROFILE: SparkRevealProfile = {
|
||
blurAmount: 0.01,
|
||
preBlurAmount: 0,
|
||
minAlpha: 0.28,
|
||
maxStdDev: Math.sqrt(1.45),
|
||
focalAdjustment: 3.05,
|
||
falloff: 0.08,
|
||
opacity: 1,
|
||
};
|
||
|
||
const SPLAT_NORMAL_PROFILE: SparkRevealProfile = {
|
||
blurAmount: 0.12,
|
||
preBlurAmount: 0.02,
|
||
minAlpha: 0.015,
|
||
maxStdDev: Math.sqrt(6.5),
|
||
focalAdjustment: 1.18,
|
||
falloff: 1,
|
||
opacity: 1,
|
||
};
|
||
|
||
function applySparkRevealProfile(
|
||
sparkRenderer: SparkRenderer,
|
||
splatMesh: SparkSplatMesh,
|
||
profile: SparkRevealProfile
|
||
) {
|
||
splatMesh.opacity = profile.opacity;
|
||
sparkRenderer.blurAmount = profile.blurAmount;
|
||
sparkRenderer.preBlurAmount = profile.preBlurAmount;
|
||
sparkRenderer.minAlpha = profile.minAlpha;
|
||
sparkRenderer.maxStdDev = profile.maxStdDev;
|
||
sparkRenderer.focalAdjustment = profile.focalAdjustment;
|
||
sparkRenderer.falloff = profile.falloff;
|
||
}
|
||
|
||
function lerpSparkRevealProfile(
|
||
from: SparkRevealProfile,
|
||
to: SparkRevealProfile,
|
||
progress: number
|
||
): SparkRevealProfile {
|
||
return {
|
||
blurAmount: THREE.MathUtils.lerp(from.blurAmount, to.blurAmount, progress),
|
||
preBlurAmount: THREE.MathUtils.lerp(from.preBlurAmount, to.preBlurAmount, progress),
|
||
minAlpha: THREE.MathUtils.lerp(from.minAlpha, to.minAlpha, progress),
|
||
maxStdDev: THREE.MathUtils.lerp(from.maxStdDev, to.maxStdDev, progress),
|
||
focalAdjustment: THREE.MathUtils.lerp(
|
||
from.focalAdjustment,
|
||
to.focalAdjustment,
|
||
progress
|
||
),
|
||
falloff: THREE.MathUtils.lerp(from.falloff, to.falloff, progress),
|
||
opacity: THREE.MathUtils.lerp(from.opacity, to.opacity, progress),
|
||
};
|
||
}
|
||
|
||
const SPLAT_REVEAL_DURATION_MS = 900;
|
||
const SPLAT_PARTICLE_SAMPLE_LIMIT = 720;
|
||
const SPLAT_WORLD_BOUNDS_SAMPLE_LIMIT = 1200;
|
||
const SPLAT_FAST_LOAD_SKIP_REVEAL_MS = 1200;
|
||
const SPARK_INLINE_LOD_BUILD_THRESHOLD = 180_000;
|
||
const SPARK_INLINE_LOD_SPLAT_SCALE = 0.78;
|
||
const SPARK_INLINE_LOD_RENDER_SCALE = 1.8;
|
||
const SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE = 0.82;
|
||
const SPARK_MINIMAL_FRAME_LOD_RENDER_SCALE = 1.6;
|
||
const SPARK_MIN_SORT_INTERVAL_MS = 24;
|
||
const SPARK_MINIMAL_FRAME_SORT_INTERVAL_MS = 40;
|
||
|
||
const KEY_TO_MOVEMENT: Partial<Record<string, MovementCommand>> = {
|
||
KeyW: "forward",
|
||
ArrowUp: "forward",
|
||
KeyS: "backward",
|
||
ArrowDown: "backward",
|
||
KeyA: "left",
|
||
ArrowLeft: "left",
|
||
KeyD: "right",
|
||
ArrowRight: "right",
|
||
KeyQ: "up",
|
||
KeyR: "up",
|
||
PageUp: "up",
|
||
KeyE: "down",
|
||
KeyF: "down",
|
||
PageDown: "down",
|
||
};
|
||
|
||
function isEditableKeyboardTarget(target: EventTarget | null) {
|
||
if (!(target instanceof HTMLElement)) return false;
|
||
if (target.isContentEditable) return true;
|
||
|
||
const editableSelector = "input, textarea, select, [contenteditable='true'], [role='textbox']";
|
||
if (target.matches(editableSelector)) return true;
|
||
return Boolean(target.closest(editableSelector));
|
||
}
|
||
|
||
function getSemanticFacts(metadata?: Record<string, unknown> | null) {
|
||
if (!metadata) return [];
|
||
|
||
const facts: Array<{ label: string; value: string }> = [];
|
||
const scaleFactor = metadata.metric_scale_factor;
|
||
if (typeof scaleFactor === "number" && Number.isFinite(scaleFactor)) {
|
||
facts.push({
|
||
label: "比例尺",
|
||
value: `${scaleFactor.toFixed(3)}x`,
|
||
});
|
||
}
|
||
|
||
const groundOffset = metadata.ground_plane_offset;
|
||
if (typeof groundOffset === "number" && Number.isFinite(groundOffset)) {
|
||
facts.push({
|
||
label: "地平面偏移",
|
||
value: `${groundOffset.toFixed(2)}m`,
|
||
});
|
||
}
|
||
|
||
return facts;
|
||
}
|
||
|
||
function seededUnit(index: number, salt: number) {
|
||
const value = Math.sin(index * 12.9898 + salt * 78.233) * 43758.5453;
|
||
return value - Math.floor(value);
|
||
}
|
||
|
||
function buildSplatParticleCloudData(
|
||
splatMesh: SparkSplatMesh,
|
||
sampleLimit = SPLAT_PARTICLE_SAMPLE_LIMIT
|
||
): SplatParticleCloudData | null {
|
||
const packedSplats = splatMesh.packedSplats;
|
||
if (!packedSplats) return null;
|
||
const totalSplats = Math.max(0, packedSplats?.numSplats || 0);
|
||
if (!totalSplats) return null;
|
||
|
||
const targetCount = Math.min(sampleLimit, totalSplats);
|
||
const sampleStep = Math.max(totalSplats / targetCount, 1);
|
||
const points: Array<{
|
||
center: THREE.Vector3;
|
||
color: THREE.Color;
|
||
opacity: number;
|
||
}> = [];
|
||
const warmWhite = new THREE.Color("#fff1dc");
|
||
|
||
for (let sampleIndex = 0; sampleIndex < targetCount; sampleIndex += 1) {
|
||
const index = Math.min(Math.floor(sampleIndex * sampleStep), totalSplats - 1);
|
||
const { center, opacity, color } = packedSplats.getSplat(index);
|
||
if (opacity < 0.035) continue;
|
||
|
||
points.push({
|
||
center,
|
||
color,
|
||
opacity,
|
||
});
|
||
}
|
||
|
||
if (!points.length) return null;
|
||
|
||
const colors = new Float32Array(points.length * 3);
|
||
const positions = new Float32Array(points.length * 3);
|
||
const seeds = new Float32Array(points.length);
|
||
|
||
points.forEach((point, index) => {
|
||
const stride = index * 3;
|
||
const displayColor = point.color
|
||
.clone()
|
||
.lerp(warmWhite, 0.28)
|
||
.multiplyScalar(0.86 + Math.min(point.opacity, 1) * 0.32);
|
||
positions[stride] = point.center.x;
|
||
positions[stride + 1] = point.center.y;
|
||
positions[stride + 2] = point.center.z;
|
||
colors[stride] = displayColor.r;
|
||
colors[stride + 1] = displayColor.g;
|
||
colors[stride + 2] = displayColor.b;
|
||
seeds[index] = seededUnit(index, 31) * Math.PI * 2;
|
||
});
|
||
|
||
return {
|
||
count: points.length,
|
||
colors,
|
||
positions,
|
||
seeds,
|
||
};
|
||
}
|
||
|
||
function getPercentileValue(values: number[], percentile: number) {
|
||
if (!values.length) return 0;
|
||
const index = THREE.MathUtils.clamp(
|
||
Math.floor((values.length - 1) * percentile),
|
||
0,
|
||
values.length - 1
|
||
);
|
||
return values[index] ?? 0;
|
||
}
|
||
|
||
function getSplatWorldBounds(splatMesh: SparkSplatMesh) {
|
||
splatMesh.updateMatrixWorld(true);
|
||
return splatMesh
|
||
.getBoundingBox(true)
|
||
.clone()
|
||
.applyMatrix4(splatMesh.matrixWorld);
|
||
}
|
||
|
||
function getRobustSplatWorldBounds(splatMesh: SparkSplatMesh) {
|
||
const packedSplats = splatMesh.packedSplats;
|
||
if (!packedSplats) {
|
||
return getSplatWorldBounds(splatMesh);
|
||
}
|
||
const totalSplats = Math.max(0, packedSplats?.numSplats || 0);
|
||
if (totalSplats < 32) {
|
||
return getSplatWorldBounds(splatMesh);
|
||
}
|
||
|
||
splatMesh.updateMatrixWorld(true);
|
||
const targetCount = Math.min(totalSplats, SPLAT_WORLD_BOUNDS_SAMPLE_LIMIT);
|
||
const sampleStep = Math.max(Math.floor(totalSplats / targetCount), 1);
|
||
const point = new THREE.Vector3();
|
||
const xs: number[] = [];
|
||
const ys: number[] = [];
|
||
const zs: number[] = [];
|
||
|
||
for (let index = 0; index < totalSplats; index += sampleStep) {
|
||
const splat = packedSplats.getSplat(index);
|
||
if (splat.opacity < 0.025) continue;
|
||
|
||
point.copy(splat.center).applyMatrix4(splatMesh.matrixWorld);
|
||
xs.push(point.x);
|
||
ys.push(point.y);
|
||
zs.push(point.z);
|
||
}
|
||
|
||
if (xs.length < 32) {
|
||
return getSplatWorldBounds(splatMesh);
|
||
}
|
||
|
||
xs.sort((a, b) => a - b);
|
||
ys.sort((a, b) => a - b);
|
||
zs.sort((a, b) => a - b);
|
||
|
||
const robustBounds = new THREE.Box3(
|
||
new THREE.Vector3(
|
||
getPercentileValue(xs, 0.02),
|
||
getPercentileValue(ys, 0.02),
|
||
getPercentileValue(zs, 0.02)
|
||
),
|
||
new THREE.Vector3(
|
||
getPercentileValue(xs, 0.98),
|
||
getPercentileValue(ys, 0.98),
|
||
getPercentileValue(zs, 0.98)
|
||
)
|
||
);
|
||
|
||
return robustBounds.isEmpty() ? getSplatWorldBounds(splatMesh) : robustBounds;
|
||
}
|
||
|
||
function positionCameraInsideWorld({
|
||
camera,
|
||
controls,
|
||
splatMesh,
|
||
groundPlaneOffset,
|
||
firstPerson = false,
|
||
}: {
|
||
camera: THREE.Camera;
|
||
controls: { target?: THREE.Vector3; update?: () => void } | null;
|
||
splatMesh: SparkSplatMesh;
|
||
groundPlaneOffset: number | null;
|
||
firstPerson?: boolean;
|
||
}): WorldCameraPose | null {
|
||
if (!(camera instanceof THREE.PerspectiveCamera)) return null;
|
||
|
||
const worldBounds = getRobustSplatWorldBounds(splatMesh);
|
||
if (worldBounds.isEmpty()) return null;
|
||
|
||
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 clampInsideAxis = (
|
||
value: number,
|
||
min: number,
|
||
max: number,
|
||
margin: number,
|
||
fallback: number
|
||
) => {
|
||
const span = Math.max(max - min, 0);
|
||
const safeMargin = Math.min(margin, span * 0.42);
|
||
const safeMin = min + safeMargin;
|
||
const safeMax = max - safeMargin;
|
||
if (safeMin > safeMax) return fallback;
|
||
return THREE.MathUtils.clamp(value, safeMin, safeMax);
|
||
};
|
||
|
||
const semanticGroundY =
|
||
groundPlaneOffset !== null &&
|
||
Number.isFinite(groundPlaneOffset) &&
|
||
groundPlaneOffset >= worldBounds.min.y - size.y * 0.25 &&
|
||
groundPlaneOffset <= worldBounds.max.y + size.y * 0.25
|
||
? THREE.MathUtils.clamp(groundPlaneOffset, worldBounds.min.y, worldBounds.max.y)
|
||
: null;
|
||
const estimatedFloorY = semanticGroundY ?? worldBounds.min.y + size.y * 0.06;
|
||
const humanEyeOffset = THREE.MathUtils.clamp(size.y * 0.18, 0.9, 1.48);
|
||
const rawEyeY = estimatedFloorY + humanEyeOffset;
|
||
const minEyeY = worldBounds.min.y + Math.max(size.y * 0.12, 0.42);
|
||
const maxEyeY = Math.min(
|
||
worldBounds.max.y - Math.max(size.y * 0.22, 0.42),
|
||
center.y + size.y * 0.16
|
||
);
|
||
const eyeY =
|
||
minEyeY <= maxEyeY
|
||
? THREE.MathUtils.clamp(rawEyeY, minEyeY, maxEyeY)
|
||
: center.y - size.y * 0.08;
|
||
const anchor = new THREE.Vector3(center.x, eyeY, center.z);
|
||
const cameraDistance = THREE.MathUtils.clamp(majorSpan * 0.16, 0.7, 1.9);
|
||
const cameraPosition = anchor
|
||
.clone()
|
||
.addScaledVector(horizontalAxis, cameraDistance);
|
||
|
||
cameraPosition.x = clampInsideAxis(
|
||
cameraPosition.x,
|
||
worldBounds.min.x,
|
||
worldBounds.max.x,
|
||
Math.max(size.x * 0.1, 0.24),
|
||
center.x
|
||
);
|
||
cameraPosition.z = clampInsideAxis(
|
||
cameraPosition.z,
|
||
worldBounds.min.z,
|
||
worldBounds.max.z,
|
||
Math.max(size.z * 0.1, 0.24),
|
||
center.z
|
||
);
|
||
|
||
const lookDirection = anchor.clone().sub(cameraPosition);
|
||
if (lookDirection.lengthSq() < 0.0001) {
|
||
lookDirection.copy(horizontalAxis).multiplyScalar(-1);
|
||
}
|
||
lookDirection.normalize();
|
||
const nearTargetPosition = cameraPosition
|
||
.clone()
|
||
.addScaledVector(lookDirection, THREE.MathUtils.clamp(majorSpan * 0.24, 0.75, 2.4));
|
||
nearTargetPosition.y = THREE.MathUtils.clamp(
|
||
eyeY - THREE.MathUtils.clamp(size.y * 0.025, 0.04, 0.18),
|
||
minEyeY <= maxEyeY ? minEyeY : worldBounds.min.y,
|
||
minEyeY <= maxEyeY ? maxEyeY : worldBounds.max.y
|
||
);
|
||
nearTargetPosition.x = clampInsideAxis(
|
||
nearTargetPosition.x,
|
||
worldBounds.min.x,
|
||
worldBounds.max.x,
|
||
Math.max(size.x * 0.08, 0.18),
|
||
center.x
|
||
);
|
||
nearTargetPosition.z = clampInsideAxis(
|
||
nearTargetPosition.z,
|
||
worldBounds.min.z,
|
||
worldBounds.max.z,
|
||
Math.max(size.z * 0.08, 0.18),
|
||
center.z
|
||
);
|
||
const orbitTargetPosition = center.clone();
|
||
const orbitRadius = Math.max(size.length() * 0.5, majorSpan * 0.55, 1.2);
|
||
const orbitDistance = THREE.MathUtils.clamp(orbitRadius * 1.85, 2.2, 14);
|
||
const targetPosition = firstPerson ? nearTargetPosition : orbitTargetPosition;
|
||
|
||
if (!firstPerson) {
|
||
cameraPosition
|
||
.copy(targetPosition)
|
||
.addScaledVector(horizontalAxis, orbitDistance)
|
||
.add(
|
||
new THREE.Vector3(
|
||
0,
|
||
THREE.MathUtils.clamp(size.y * 0.08, 0.18, 1.2),
|
||
0
|
||
)
|
||
);
|
||
}
|
||
|
||
camera.position.copy(cameraPosition);
|
||
camera.lookAt(targetPosition);
|
||
camera.updateProjectionMatrix();
|
||
|
||
if (controls?.target) {
|
||
controls.target.copy(targetPosition);
|
||
controls.update?.();
|
||
}
|
||
|
||
return {
|
||
cameraPosition: cameraPosition.clone(),
|
||
targetPosition: targetPosition.clone(),
|
||
};
|
||
}
|
||
|
||
function WorldEmptyState({
|
||
icon: Icon,
|
||
title,
|
||
description,
|
||
fillFrame = false,
|
||
previewImageUrl,
|
||
}: {
|
||
icon: React.ComponentType<{ className?: string }>;
|
||
title: string;
|
||
description: string;
|
||
fillFrame?: boolean;
|
||
previewImageUrl?: string | null;
|
||
}) {
|
||
if (previewImageUrl && fillFrame) {
|
||
return (
|
||
<div className="relative h-full w-full overflow-hidden bg-zinc-950">
|
||
<img
|
||
alt={title}
|
||
className="h-full w-full object-cover"
|
||
src={previewImageUrl}
|
||
/>
|
||
<div className="absolute inset-0 flex items-center justify-center bg-black/10">
|
||
<Loader2 className="h-7 w-7 animate-spin text-white/55 drop-shadow" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (fillFrame) {
|
||
return (
|
||
<div className="flex h-full w-full items-center justify-center bg-zinc-950/70">
|
||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-zinc-900/60 shadow-inner">
|
||
<Icon className="h-10 w-10 text-zinc-700" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 bg-zinc-950/70 p-6 text-center">
|
||
{previewImageUrl ? (
|
||
<div className="relative aspect-video w-full max-w-xl overflow-hidden rounded-2xl border border-white/10 bg-black/40 shadow-2xl">
|
||
<img
|
||
alt={title}
|
||
className="h-full w-full object-cover opacity-50"
|
||
src={previewImageUrl}
|
||
/>
|
||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 backdrop-blur-[1px]">
|
||
<Loader2 className="h-8 w-8 animate-spin text-white/40" />
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-zinc-900/60 shadow-inner">
|
||
<Icon className="h-10 w-10 text-zinc-700" />
|
||
</div>
|
||
)}
|
||
<div className="space-y-2">
|
||
<p className="font-medium text-lg text-zinc-200">{title}</p>
|
||
<p className="max-w-md text-sm text-zinc-500">{description}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SplatParticleCloud({
|
||
data,
|
||
phase,
|
||
}: {
|
||
data: SplatParticleCloudData;
|
||
phase: SplatLoadingPhase;
|
||
}) {
|
||
const geometryRef = useRef<THREE.BufferGeometry>(null);
|
||
const materialRef = useRef<THREE.PointsMaterial>(null);
|
||
const pointsRef = useRef<THREE.Points>(null);
|
||
const phaseRef = useRef<SplatLoadingPhase>(phase);
|
||
const phaseStartedAtRef = useRef(performance.now());
|
||
const positionBuffer = useMemo(() => data.positions.slice(), [data.positions]);
|
||
|
||
useEffect(() => {
|
||
if (phaseRef.current !== phase) {
|
||
phaseRef.current = phase;
|
||
phaseStartedAtRef.current = performance.now();
|
||
}
|
||
}, [phase]);
|
||
|
||
useEffect(() => {
|
||
if (pointsRef.current) {
|
||
pointsRef.current.raycast = () => null;
|
||
}
|
||
}, []);
|
||
|
||
useFrame((state) => {
|
||
const geometry = geometryRef.current;
|
||
const material = materialRef.current;
|
||
if (!geometry || !material) return;
|
||
|
||
const phaseNow = phaseRef.current;
|
||
const elapsedSincePhase = performance.now() - phaseStartedAtRef.current;
|
||
const revealProgress =
|
||
phaseNow === "revealing"
|
||
? THREE.MathUtils.smoothstep(
|
||
Math.min(elapsedSincePhase / SPLAT_REVEAL_DURATION_MS, 1),
|
||
0,
|
||
1
|
||
)
|
||
: phaseNow === "hidden"
|
||
? 1
|
||
: 0;
|
||
|
||
const particleIntroEnd = 0.18;
|
||
const particleHoldEnd = 0.62;
|
||
let particleOpacity = 0;
|
||
let particleSize = 0.01;
|
||
|
||
if (phaseNow === "revealing") {
|
||
if (revealProgress < particleIntroEnd) {
|
||
const intro = THREE.MathUtils.smoothstep(revealProgress / particleIntroEnd, 0, 1);
|
||
particleOpacity = THREE.MathUtils.lerp(0.08, 0.92, intro);
|
||
particleSize = THREE.MathUtils.lerp(0.01, 0.028, intro);
|
||
} else if (revealProgress < particleHoldEnd) {
|
||
particleOpacity = 0.92;
|
||
particleSize = 0.028;
|
||
} else {
|
||
const outro = THREE.MathUtils.smoothstep(
|
||
(revealProgress - particleHoldEnd) / (1 - particleHoldEnd),
|
||
0,
|
||
1
|
||
);
|
||
particleOpacity = THREE.MathUtils.lerp(0.92, 0, outro);
|
||
particleSize = THREE.MathUtils.lerp(0.028, 0.006, outro);
|
||
}
|
||
}
|
||
|
||
const positionAttribute = geometry.getAttribute("position");
|
||
const positions = positionAttribute.array as Float32Array;
|
||
const time = state.clock.getElapsedTime();
|
||
const jitterStrength = phaseNow === "revealing" ? (1 - revealProgress) * 0.012 : 0;
|
||
|
||
for (let index = 0; index < data.count; index += 1) {
|
||
const stride = index * 3;
|
||
const seed = data.seeds[index] || 0;
|
||
positions[stride] = data.positions[stride] + Math.sin(time * 2.8 + seed) * jitterStrength;
|
||
positions[stride + 1] =
|
||
data.positions[stride + 1] +
|
||
Math.cos(time * 2.3 + seed * 1.7) * jitterStrength * 0.7;
|
||
positions[stride + 2] =
|
||
data.positions[stride + 2] + Math.sin(time * 2.1 + seed * 0.6) * jitterStrength;
|
||
}
|
||
|
||
positionAttribute.needsUpdate = true;
|
||
material.opacity = particleOpacity;
|
||
material.size = particleSize;
|
||
material.visible = particleOpacity > 0.001;
|
||
});
|
||
|
||
return (
|
||
<points
|
||
frustumCulled={false}
|
||
ref={pointsRef}
|
||
renderOrder={11}
|
||
>
|
||
<bufferGeometry ref={geometryRef}>
|
||
<bufferAttribute attach="attributes-position" args={[positionBuffer, 3]} />
|
||
<bufferAttribute attach="attributes-color" args={[data.colors, 3]} />
|
||
</bufferGeometry>
|
||
<pointsMaterial
|
||
blending={THREE.AdditiveBlending}
|
||
depthTest
|
||
depthWrite={false}
|
||
ref={materialRef}
|
||
size={0.02}
|
||
sizeAttenuation
|
||
toneMapped={false}
|
||
transparent
|
||
vertexColors
|
||
/>
|
||
</points>
|
||
);
|
||
}
|
||
|
||
function WorldSplatLoadingOverlay({
|
||
previewImageUrl,
|
||
phase,
|
||
}: {
|
||
previewImageUrl?: string | null;
|
||
phase: Exclude<SplatLoadingPhase, "hidden">;
|
||
}) {
|
||
const isRevealing = phase === "revealing";
|
||
const isLoading = phase === "loading";
|
||
|
||
return (
|
||
<>
|
||
{previewImageUrl ? (
|
||
<div
|
||
className={cn(
|
||
"pointer-events-none absolute inset-0 overflow-hidden transition-opacity duration-500",
|
||
isLoading ? "opacity-100" : "opacity-30"
|
||
)}
|
||
>
|
||
<img
|
||
alt="世界预览"
|
||
className={cn(
|
||
"absolute inset-0 h-full w-full object-cover transition-transform duration-700",
|
||
isLoading ? "scale-[1.01]" : "scale-100"
|
||
)}
|
||
decoding="async"
|
||
fetchPriority="high"
|
||
src={previewImageUrl}
|
||
/>
|
||
<div
|
||
className={cn(
|
||
"absolute inset-0 transition-opacity duration-500",
|
||
isLoading
|
||
? "bg-gradient-to-b from-black/10 via-black/12 to-black/30"
|
||
: "bg-gradient-to-b from-black/0 via-black/5 to-black/18"
|
||
)}
|
||
/>
|
||
</div>
|
||
) : phase === "loading" ? (
|
||
<div className="pointer-events-none absolute inset-0 overflow-hidden transition-opacity duration-300">
|
||
{previewImageUrl ? (
|
||
<img
|
||
alt="世界预览"
|
||
className="absolute inset-0 h-full w-full object-cover opacity-[0.1] blur-[10px] saturate-[0.72]"
|
||
decoding="async"
|
||
fetchPriority="high"
|
||
src={previewImageUrl}
|
||
/>
|
||
) : null}
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_rgba(255,245,220,0.05)_0%,_rgba(11,15,25,0.08)_24%,_rgba(1,2,6,0.38)_100%)]" />
|
||
</div>
|
||
) : null}
|
||
|
||
</>
|
||
);
|
||
}
|
||
|
||
function createMovementState(): MovementState {
|
||
return { ...EMPTY_MOVEMENT_STATE };
|
||
}
|
||
|
||
function CameraMovementControls({
|
||
enabled,
|
||
firstPerson,
|
||
movementStateRef,
|
||
orbitControlsRef,
|
||
}: {
|
||
enabled: boolean;
|
||
firstPerson: boolean;
|
||
movementStateRef?: React.MutableRefObject<MovementState>;
|
||
orbitControlsRef: React.MutableRefObject<any>;
|
||
}) {
|
||
const { camera } = useThree();
|
||
const pressedKeysRef = useRef<MovementState>(createMovementState());
|
||
const directionRef = useRef(new THREE.Vector3());
|
||
const rightRef = useRef(new THREE.Vector3());
|
||
const upRef = useRef(new THREE.Vector3());
|
||
const moveRef = useRef(new THREE.Vector3());
|
||
|
||
useEffect(() => {
|
||
if (!enabled) {
|
||
pressedKeysRef.current = createMovementState();
|
||
if (movementStateRef) {
|
||
movementStateRef.current = createMovementState();
|
||
}
|
||
return;
|
||
}
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
const movement = KEY_TO_MOVEMENT[event.code];
|
||
if (!movement) return;
|
||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||
if (isEditableKeyboardTarget(event.target)) {
|
||
pressedKeysRef.current = createMovementState();
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
pressedKeysRef.current[movement] = true;
|
||
};
|
||
const handleKeyUp = (event: KeyboardEvent) => {
|
||
const movement = KEY_TO_MOVEMENT[event.code];
|
||
if (!movement) return;
|
||
if (isEditableKeyboardTarget(event.target)) {
|
||
pressedKeysRef.current = createMovementState();
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
pressedKeysRef.current[movement] = false;
|
||
};
|
||
const handleFocusIn = (event: FocusEvent) => {
|
||
if (isEditableKeyboardTarget(event.target)) {
|
||
pressedKeysRef.current = createMovementState();
|
||
}
|
||
};
|
||
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
window.addEventListener("keyup", handleKeyUp);
|
||
window.addEventListener("focusin", handleFocusIn);
|
||
|
||
return () => {
|
||
window.removeEventListener("keydown", handleKeyDown);
|
||
window.removeEventListener("keyup", handleKeyUp);
|
||
window.removeEventListener("focusin", handleFocusIn);
|
||
};
|
||
}, [enabled, movementStateRef]);
|
||
|
||
useFrame((_, delta) => {
|
||
if (!enabled) return;
|
||
|
||
const pressed = pressedKeysRef.current;
|
||
const padPressed = movementStateRef?.current;
|
||
const hasMovement =
|
||
Object.values(pressed).some(Boolean) ||
|
||
Boolean(padPressed && Object.values(padPressed).some(Boolean));
|
||
if (!hasMovement) return;
|
||
|
||
const moveSpeed = firstPerson ? 3.8 : 2.8;
|
||
const distance = moveSpeed * delta;
|
||
|
||
camera.getWorldDirection(directionRef.current);
|
||
directionRef.current.setY(0).normalize();
|
||
rightRef.current.crossVectors(directionRef.current, camera.up).normalize();
|
||
upRef.current.copy(camera.up).normalize();
|
||
|
||
moveRef.current.set(0, 0, 0);
|
||
|
||
if (pressed.forward || padPressed?.forward) {
|
||
moveRef.current.add(directionRef.current);
|
||
}
|
||
if (pressed.backward || padPressed?.backward) {
|
||
moveRef.current.addScaledVector(directionRef.current, -1);
|
||
}
|
||
if (pressed.left || padPressed?.left) {
|
||
moveRef.current.addScaledVector(rightRef.current, -1);
|
||
}
|
||
if (pressed.right || padPressed?.right) {
|
||
moveRef.current.add(rightRef.current);
|
||
}
|
||
if (pressed.up || padPressed?.up) {
|
||
moveRef.current.add(upRef.current);
|
||
}
|
||
if (pressed.down || padPressed?.down) {
|
||
moveRef.current.addScaledVector(upRef.current, -1);
|
||
}
|
||
|
||
if (moveRef.current.lengthSq() === 0) return;
|
||
|
||
moveRef.current.normalize().multiplyScalar(distance);
|
||
camera.position.add(moveRef.current);
|
||
|
||
if (orbitControlsRef.current?.target) {
|
||
orbitControlsRef.current.target.add(moveRef.current);
|
||
}
|
||
orbitControlsRef.current?.update?.();
|
||
});
|
||
|
||
return null;
|
||
}
|
||
|
||
function WorldMovementPad({
|
||
movementStateRef,
|
||
}: {
|
||
movementStateRef: React.MutableRefObject<MovementState>;
|
||
}) {
|
||
const setPressed = React.useCallback(
|
||
(command: MovementCommand, pressed: boolean) => {
|
||
movementStateRef.current[command] = pressed;
|
||
},
|
||
[movementStateRef]
|
||
);
|
||
const clearMovement = React.useCallback(() => {
|
||
movementStateRef.current = createMovementState();
|
||
}, [movementStateRef]);
|
||
const buttonClass =
|
||
"flex h-8 w-8 select-none items-center justify-center rounded-[8px] border border-white/15 bg-black/60 text-[12px] font-bold text-white shadow-[0_8px_22px_rgba(0,0,0,0.28)] backdrop-blur-md transition hover:bg-black/75 active:scale-95";
|
||
const buttonProps = (command: MovementCommand) => ({
|
||
"aria-label": command,
|
||
className: buttonClass,
|
||
onPointerDown: (event: React.PointerEvent<HTMLButtonElement>) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||
setPressed(command, true);
|
||
},
|
||
onPointerUp: (event: React.PointerEvent<HTMLButtonElement>) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setPressed(command, false);
|
||
},
|
||
onPointerCancel: (event: React.PointerEvent<HTMLButtonElement>) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
clearMovement();
|
||
},
|
||
onPointerLeave: (event: React.PointerEvent<HTMLButtonElement>) => {
|
||
event.stopPropagation();
|
||
setPressed(command, false);
|
||
},
|
||
type: "button" as const,
|
||
});
|
||
|
||
return (
|
||
<div
|
||
className="nodrag nopan nowheel pointer-events-auto absolute bottom-3 left-3 z-30 flex items-end gap-2"
|
||
data-canvas-no-drag="true"
|
||
onContextMenu={(event) => event.preventDefault()}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onPointerUp={(event) => event.stopPropagation()}
|
||
onWheel={(event) => event.stopPropagation()}
|
||
>
|
||
<div className="grid grid-cols-3 gap-1 rounded-[12px] border border-white/10 bg-black/28 p-1.5 backdrop-blur-md">
|
||
<span />
|
||
<button {...buttonProps("forward")}>W</button>
|
||
<span />
|
||
<button {...buttonProps("left")}>A</button>
|
||
<button {...buttonProps("backward")}>S</button>
|
||
<button {...buttonProps("right")}>D</button>
|
||
</div>
|
||
<div className="grid gap-1 rounded-[12px] border border-white/10 bg-black/28 p-1.5 backdrop-blur-md">
|
||
<button {...buttonProps("up")}>Q</button>
|
||
<button {...buttonProps("down")}>E</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FirstPersonLookControls({
|
||
enabled,
|
||
resetToken,
|
||
}: {
|
||
enabled: boolean;
|
||
resetToken?: number;
|
||
}) {
|
||
const { camera, gl } = useThree();
|
||
const yawRef = useRef(0);
|
||
const pitchRef = useRef(0);
|
||
const draggingPointerIdRef = useRef<number | null>(null);
|
||
const directionRef = useRef(new THREE.Vector3());
|
||
|
||
const syncAnglesFromCamera = React.useCallback(() => {
|
||
camera.getWorldDirection(directionRef.current).normalize();
|
||
yawRef.current = Math.atan2(directionRef.current.x, directionRef.current.z);
|
||
pitchRef.current = Math.asin(
|
||
THREE.MathUtils.clamp(directionRef.current.y, -0.98, 0.98)
|
||
);
|
||
}, [camera]);
|
||
|
||
const applyLookDirection = React.useCallback(() => {
|
||
const pitch = THREE.MathUtils.clamp(
|
||
pitchRef.current,
|
||
-Math.PI / 2 + 0.08,
|
||
Math.PI / 2 - 0.08
|
||
);
|
||
pitchRef.current = pitch;
|
||
|
||
const cosPitch = Math.cos(pitch);
|
||
directionRef.current.set(
|
||
Math.sin(yawRef.current) * cosPitch,
|
||
Math.sin(pitch),
|
||
Math.cos(yawRef.current) * cosPitch
|
||
);
|
||
camera.lookAt(camera.position.clone().add(directionRef.current));
|
||
}, [camera]);
|
||
|
||
useEffect(() => {
|
||
if (!enabled) return;
|
||
syncAnglesFromCamera();
|
||
applyLookDirection();
|
||
}, [applyLookDirection, enabled, resetToken, syncAnglesFromCamera]);
|
||
|
||
useEffect(() => {
|
||
if (!enabled) return;
|
||
|
||
const canvas = gl.domElement;
|
||
const sensitivity = 0.0032;
|
||
|
||
const handlePointerDown = (event: PointerEvent) => {
|
||
if (event.button !== 0) return;
|
||
if (isEditableKeyboardTarget(event.target)) return;
|
||
syncAnglesFromCamera();
|
||
draggingPointerIdRef.current = event.pointerId;
|
||
canvas.setPointerCapture?.(event.pointerId);
|
||
event.preventDefault();
|
||
};
|
||
|
||
const handlePointerMove = (event: PointerEvent) => {
|
||
if (draggingPointerIdRef.current !== event.pointerId) return;
|
||
yawRef.current -= event.movementX * sensitivity;
|
||
pitchRef.current -= event.movementY * sensitivity;
|
||
applyLookDirection();
|
||
event.preventDefault();
|
||
};
|
||
|
||
const stopDragging = (event: PointerEvent) => {
|
||
if (draggingPointerIdRef.current !== event.pointerId) return;
|
||
draggingPointerIdRef.current = null;
|
||
canvas.releasePointerCapture?.(event.pointerId);
|
||
event.preventDefault();
|
||
};
|
||
|
||
canvas.addEventListener("pointerdown", handlePointerDown);
|
||
canvas.addEventListener("pointermove", handlePointerMove);
|
||
canvas.addEventListener("pointerup", stopDragging);
|
||
canvas.addEventListener("pointercancel", stopDragging);
|
||
|
||
return () => {
|
||
draggingPointerIdRef.current = null;
|
||
canvas.removeEventListener("pointerdown", handlePointerDown);
|
||
canvas.removeEventListener("pointermove", handlePointerMove);
|
||
canvas.removeEventListener("pointerup", stopDragging);
|
||
canvas.removeEventListener("pointercancel", stopDragging);
|
||
};
|
||
}, [applyLookDirection, enabled, gl.domElement, syncAnglesFromCamera]);
|
||
|
||
return null;
|
||
}
|
||
|
||
function HotspotBillboard({
|
||
hotspot,
|
||
onSelect,
|
||
}: {
|
||
hotspot: World3DHotspot;
|
||
onSelect?: (hotspotId: string) => void;
|
||
}) {
|
||
return (
|
||
<group position={[hotspot.position.x, hotspot.position.y, hotspot.position.z]}>
|
||
<mesh
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onSelect?.(hotspot.id);
|
||
}}
|
||
>
|
||
<sphereGeometry args={[0.07, 24, 24]} />
|
||
<meshStandardMaterial color="#facc15" emissive="#ca8a04" emissiveIntensity={0.8} />
|
||
</mesh>
|
||
<Html center distanceFactor={8} position={[0, 0.14, 0]}>
|
||
<div className="rounded-full border border-yellow-400/30 bg-black/70 px-2 py-1 text-[10px] font-medium text-yellow-200 shadow-lg backdrop-blur-sm">
|
||
{hotspot.label}
|
||
</div>
|
||
</Html>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
function WorldSplatScene({
|
||
splatUrl,
|
||
revealPhase = "loading",
|
||
firstPerson = false,
|
||
minimalFrame = false,
|
||
hotspots = [],
|
||
interactionMode = "navigate",
|
||
onPick,
|
||
onHotspotSelect,
|
||
semanticsMetadata,
|
||
onLoadStateChange,
|
||
onRevealComplete,
|
||
resetViewToken,
|
||
showInfoOverlay = true,
|
||
movementStateRef,
|
||
movementEnabled = false,
|
||
}: {
|
||
splatUrl: string;
|
||
revealPhase?: SplatLoadingPhase;
|
||
firstPerson?: boolean;
|
||
minimalFrame?: boolean;
|
||
hotspots?: World3DHotspot[];
|
||
interactionMode?: "navigate" | "hotspot";
|
||
onPick?: (pick: World3DPickResult) => void;
|
||
onHotspotSelect?: (hotspotId: string) => void;
|
||
semanticsMetadata?: Record<string, unknown> | null;
|
||
onLoadStateChange?: (state: "ready" | "error") => void;
|
||
onRevealComplete?: () => void;
|
||
resetViewToken?: number;
|
||
showInfoOverlay?: boolean;
|
||
movementStateRef?: React.MutableRefObject<MovementState>;
|
||
movementEnabled?: boolean;
|
||
}) {
|
||
const camera = useThree((state) => state.camera);
|
||
const renderer = useThree((state) => state.gl);
|
||
const orbitControlsRef = useRef<any>(null);
|
||
const hasAppliedInitialViewRef = useRef(false);
|
||
const lastCameraPoseRef = useRef<WorldCameraPose | null>(null);
|
||
const lastResetViewTokenRef = useRef<number | undefined>(resetViewToken);
|
||
const lastFirstPersonRef = useRef(firstPerson);
|
||
const revealCompletedRef = useRef(false);
|
||
const [controlsMountVersion, setControlsMountVersion] = useState(0);
|
||
const [particleCloudData, setParticleCloudData] = useState<SplatParticleCloudData | null>(null);
|
||
const semanticFacts = useMemo(
|
||
() => getSemanticFacts(semanticsMetadata),
|
||
[semanticsMetadata]
|
||
);
|
||
const revealPhaseRef = useRef<SplatLoadingPhase>(revealPhase);
|
||
const revealStartedAtRef = useRef(performance.now());
|
||
const sparkRenderer = useMemo(
|
||
() =>
|
||
new SparkRenderer({
|
||
renderer,
|
||
blurAmount: 0.18,
|
||
maxStdDev: Math.sqrt(7),
|
||
sortRadial: true,
|
||
minSortIntervalMs: minimalFrame
|
||
? SPARK_MINIMAL_FRAME_SORT_INTERVAL_MS
|
||
: SPARK_MIN_SORT_INTERVAL_MS,
|
||
enableLod: true,
|
||
enableLodFetching: false,
|
||
lodSplatScale: minimalFrame
|
||
? SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE
|
||
: SPARK_INLINE_LOD_SPLAT_SCALE,
|
||
lodRenderScale: minimalFrame
|
||
? SPARK_MINIMAL_FRAME_LOD_RENDER_SCALE
|
||
: SPARK_INLINE_LOD_RENDER_SCALE,
|
||
numLodFetchers: 2,
|
||
}),
|
||
[minimalFrame, renderer]
|
||
);
|
||
const splatMesh = useMemo(
|
||
() =>
|
||
new SparkSplatMesh({
|
||
fileType: SplatFileType.SPZ,
|
||
url: splatUrl,
|
||
lod: true,
|
||
nonLod: true,
|
||
lodAbove: SPARK_INLINE_LOD_BUILD_THRESHOLD,
|
||
enableLod: true,
|
||
lodScale: minimalFrame
|
||
? SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE
|
||
: SPARK_INLINE_LOD_SPLAT_SCALE,
|
||
}),
|
||
[minimalFrame, splatUrl]
|
||
);
|
||
const groundPlaneOffset =
|
||
typeof semanticsMetadata?.ground_plane_offset === "number"
|
||
? semanticsMetadata.ground_plane_offset
|
||
: null;
|
||
const handleOrbitControlsRef = React.useCallback((controls: any | null) => {
|
||
orbitControlsRef.current = controls;
|
||
setControlsMountVersion((version) => version + 1);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
sparkRenderer.frustumCulled = false;
|
||
sparkRenderer.renderOrder = 10;
|
||
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
|
||
return () => {
|
||
sparkRenderer.removeFromParent();
|
||
sparkRenderer.dispose();
|
||
};
|
||
}, [sparkRenderer, splatMesh]);
|
||
|
||
useEffect(() => {
|
||
if (revealPhaseRef.current !== revealPhase) {
|
||
revealPhaseRef.current = revealPhase;
|
||
revealStartedAtRef.current = performance.now();
|
||
if (revealPhase === "loading") {
|
||
revealCompletedRef.current = false;
|
||
}
|
||
}
|
||
}, [revealPhase]);
|
||
|
||
useEffect(() => {
|
||
hasAppliedInitialViewRef.current = false;
|
||
lastCameraPoseRef.current = null;
|
||
lastResetViewTokenRef.current = resetViewToken;
|
||
revealCompletedRef.current = false;
|
||
setParticleCloudData(null);
|
||
splatMesh.frustumCulled = false;
|
||
splatMesh.rotation.set(0, 0, 0);
|
||
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE);
|
||
|
||
let cancelled = false;
|
||
let idleCallbackId: number | null = null;
|
||
let timeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||
splatMesh.initialized
|
||
.then(() => {
|
||
if (cancelled) return;
|
||
window.requestAnimationFrame(() => {
|
||
if (cancelled || hasAppliedInitialViewRef.current) return;
|
||
lastCameraPoseRef.current = positionCameraInsideWorld({
|
||
camera,
|
||
controls: orbitControlsRef.current,
|
||
firstPerson,
|
||
groundPlaneOffset,
|
||
splatMesh,
|
||
});
|
||
hasAppliedInitialViewRef.current = true;
|
||
onLoadStateChange?.("ready");
|
||
|
||
const buildParticleCloud = () => {
|
||
if (cancelled || revealPhaseRef.current === "hidden") return;
|
||
setParticleCloudData(buildSplatParticleCloudData(splatMesh));
|
||
};
|
||
|
||
if ("requestIdleCallback" in window) {
|
||
idleCallbackId = window.requestIdleCallback(buildParticleCloud, {
|
||
timeout: 250,
|
||
});
|
||
} else {
|
||
timeoutId = globalThis.setTimeout(buildParticleCloud, 160);
|
||
}
|
||
});
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) onLoadStateChange?.("error");
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
if (idleCallbackId !== null && "cancelIdleCallback" in window) {
|
||
window.cancelIdleCallback(idleCallbackId);
|
||
}
|
||
if (timeoutId !== null) {
|
||
globalThis.clearTimeout(timeoutId);
|
||
}
|
||
splatMesh.dispose();
|
||
};
|
||
}, [camera, firstPerson, groundPlaneOffset, onLoadStateChange, sparkRenderer, splatMesh]);
|
||
|
||
useEffect(() => {
|
||
if (!hasAppliedInitialViewRef.current) return;
|
||
if (lastResetViewTokenRef.current === resetViewToken) return;
|
||
lastResetViewTokenRef.current = resetViewToken;
|
||
lastCameraPoseRef.current = positionCameraInsideWorld({
|
||
camera,
|
||
controls: orbitControlsRef.current,
|
||
firstPerson,
|
||
groundPlaneOffset,
|
||
splatMesh,
|
||
});
|
||
}, [camera, firstPerson, groundPlaneOffset, resetViewToken, splatMesh]);
|
||
|
||
useEffect(() => {
|
||
if (!hasAppliedInitialViewRef.current) {
|
||
lastFirstPersonRef.current = firstPerson;
|
||
return;
|
||
}
|
||
|
||
if (lastFirstPersonRef.current !== firstPerson) {
|
||
lastCameraPoseRef.current = positionCameraInsideWorld({
|
||
camera,
|
||
controls: orbitControlsRef.current,
|
||
firstPerson,
|
||
groundPlaneOffset,
|
||
splatMesh,
|
||
});
|
||
}
|
||
|
||
lastFirstPersonRef.current = firstPerson;
|
||
}, [camera, firstPerson, groundPlaneOffset, splatMesh]);
|
||
|
||
useEffect(() => {
|
||
if (!orbitControlsRef.current || !lastCameraPoseRef.current) return;
|
||
|
||
camera.position.copy(lastCameraPoseRef.current.cameraPosition);
|
||
camera.lookAt(lastCameraPoseRef.current.targetPosition);
|
||
if (camera instanceof THREE.PerspectiveCamera) {
|
||
camera.updateProjectionMatrix();
|
||
}
|
||
orbitControlsRef.current.target?.copy(lastCameraPoseRef.current.targetPosition);
|
||
orbitControlsRef.current.update?.();
|
||
}, [camera, controlsMountVersion]);
|
||
|
||
useFrame(() => {
|
||
const phase = revealPhaseRef.current;
|
||
const revealElapsed =
|
||
phase === "revealing"
|
||
? Math.min((performance.now() - revealStartedAtRef.current) / SPLAT_REVEAL_DURATION_MS, 1)
|
||
: 0;
|
||
const easedReveal = THREE.MathUtils.smoothstep(revealElapsed, 0, 1);
|
||
|
||
if (phase === "loading") {
|
||
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE);
|
||
} else if (phase === "revealing") {
|
||
const particleIntroEnd = 0.2;
|
||
const particleHoldEnd = 0.6;
|
||
let profile = SPLAT_PARTICLE_PROFILE;
|
||
|
||
if (easedReveal < particleIntroEnd) {
|
||
profile = lerpSparkRevealProfile(
|
||
SPLAT_LOADING_PROFILE,
|
||
SPLAT_PARTICLE_PROFILE,
|
||
THREE.MathUtils.smoothstep(easedReveal / particleIntroEnd, 0, 1)
|
||
);
|
||
} else if (easedReveal < particleHoldEnd) {
|
||
const pulse = Math.sin((performance.now() - revealStartedAtRef.current) * 0.008) * 0.02;
|
||
profile = {
|
||
...SPLAT_PARTICLE_PROFILE,
|
||
minAlpha: SPLAT_PARTICLE_PROFILE.minAlpha + pulse,
|
||
focalAdjustment: SPLAT_PARTICLE_PROFILE.focalAdjustment + pulse * 2.4,
|
||
};
|
||
} else {
|
||
const granularProgress = (easedReveal - particleHoldEnd) / (1 - particleHoldEnd);
|
||
profile = lerpSparkRevealProfile(
|
||
SPLAT_PARTICLE_PROFILE,
|
||
SPLAT_NORMAL_PROFILE,
|
||
THREE.MathUtils.smoothstep(granularProgress, 0, 1)
|
||
);
|
||
}
|
||
applySparkRevealProfile(sparkRenderer, splatMesh, profile);
|
||
if (revealElapsed >= 1 && !revealCompletedRef.current) {
|
||
revealCompletedRef.current = true;
|
||
onRevealComplete?.();
|
||
}
|
||
} else {
|
||
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
|
||
}
|
||
|
||
sparkRenderer.dirty = true;
|
||
});
|
||
|
||
const handlePointerDown = (event: ThreeEvent<PointerEvent>) => {
|
||
if (interactionMode !== "hotspot") return;
|
||
event.stopPropagation();
|
||
onPick?.({
|
||
point: [event.point.x, event.point.y, event.point.z],
|
||
});
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<color args={["#020617"]} attach="background" />
|
||
<fog attach="fog" args={["#020617", 10, 45]} />
|
||
<ambientLight intensity={1.6} />
|
||
<directionalLight intensity={1.25} position={[6, 10, 4]} />
|
||
{groundPlaneOffset !== null ? (
|
||
<gridHelper
|
||
args={[18, 18, "#334155", "#0f172a"]}
|
||
position={[0, groundPlaneOffset, 0]}
|
||
/>
|
||
) : null}
|
||
<Suspense fallback={null}>
|
||
<primitive object={sparkRenderer}>
|
||
<primitive object={splatMesh} onPointerDown={handlePointerDown} />
|
||
</primitive>
|
||
</Suspense>
|
||
{particleCloudData ? <SplatParticleCloud data={particleCloudData} phase={revealPhase} /> : null}
|
||
{hotspots.map((hotspot) => (
|
||
<HotspotBillboard
|
||
hotspot={hotspot}
|
||
key={hotspot.id}
|
||
onSelect={onHotspotSelect}
|
||
/>
|
||
))}
|
||
<CameraMovementControls
|
||
enabled={movementEnabled && interactionMode === "navigate"}
|
||
firstPerson={firstPerson}
|
||
movementStateRef={movementStateRef}
|
||
orbitControlsRef={orbitControlsRef}
|
||
/>
|
||
<FirstPersonLookControls
|
||
enabled={interactionMode === "navigate" && firstPerson}
|
||
resetToken={resetViewToken}
|
||
/>
|
||
{!firstPerson ? (
|
||
<OrbitControls
|
||
autoRotate={false}
|
||
dampingFactor={0.08}
|
||
enableDamping
|
||
enablePan={false}
|
||
enableRotate
|
||
enableZoom
|
||
maxDistance={16}
|
||
key="world-splat-orbit"
|
||
makeDefault
|
||
mouseButtons={{
|
||
LEFT: THREE.MOUSE.ROTATE,
|
||
MIDDLE: THREE.MOUSE.DOLLY,
|
||
RIGHT: THREE.MOUSE.ROTATE,
|
||
}}
|
||
minDistance={0.5}
|
||
ref={handleOrbitControlsRef}
|
||
/>
|
||
) : null}
|
||
<Html className="pointer-events-none" fullscreen>
|
||
<div className="flex h-full w-full flex-col justify-between p-4">
|
||
<div className="flex items-start justify-between">
|
||
{showInfoOverlay ? (
|
||
<div className="flex gap-2">
|
||
<Badge className="border-cyan-500/30 bg-cyan-500/10 text-cyan-200" variant="outline">
|
||
SPZ
|
||
</Badge>
|
||
{semanticFacts.map((fact) => (
|
||
<Badge
|
||
className="border-white/10 bg-black/50 text-zinc-200"
|
||
key={fact.label}
|
||
variant="outline"
|
||
>
|
||
{fact.label} {fact.value}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div />
|
||
)}
|
||
{interactionMode === "hotspot" ? (
|
||
<div className="rounded-full border border-yellow-400/30 bg-black/65 px-3 py-1 text-[11px] text-yellow-100">
|
||
点击场景放置热点
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{(firstPerson || interactionMode === "hotspot") && (
|
||
<div className="flex items-end justify-between gap-3">
|
||
{firstPerson && interactionMode === "navigate" ? (
|
||
<div className="rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-[10px] text-zinc-300 shadow-xl backdrop-blur-sm">
|
||
WASD / 方向键移动,拖动画面转向,Q/R 上升,E/F 下降
|
||
</div>
|
||
) : (
|
||
<div />
|
||
)}
|
||
<div className="flex h-12 w-12 shrink-0 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>
|
||
</Html>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function WorldSplatCaptureBridge({
|
||
onCaptureReady,
|
||
}: {
|
||
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
||
}) {
|
||
const { camera, gl, scene } = useThree();
|
||
|
||
useEffect(() => {
|
||
if (!onCaptureReady) return;
|
||
|
||
onCaptureReady(async () => {
|
||
const canvas = gl.domElement;
|
||
gl.render(scene, camera);
|
||
|
||
const blob = await new Promise<Blob | null>((resolve) => {
|
||
if (typeof canvas.toBlob === "function") {
|
||
canvas.toBlob(resolve, "image/png");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const dataUrl = canvas.toDataURL("image/png");
|
||
fetch(dataUrl)
|
||
.then((response) => response.blob())
|
||
.then((result) => resolve(result))
|
||
.catch(() => resolve(null));
|
||
} catch {
|
||
resolve(null);
|
||
}
|
||
});
|
||
|
||
return blob;
|
||
});
|
||
|
||
return () => onCaptureReady(null);
|
||
}, [camera, gl, onCaptureReady, scene]);
|
||
|
||
return null;
|
||
}
|
||
|
||
function WorldSplatViewer({
|
||
splatUrl,
|
||
worldUrl,
|
||
previewImageUrl,
|
||
firstPerson,
|
||
hotspots,
|
||
interactionMode,
|
||
onPick,
|
||
onHotspotSelect,
|
||
semanticsMetadata,
|
||
resetViewToken,
|
||
minimalFrame = false,
|
||
onCaptureReady,
|
||
showInfoOverlay = true,
|
||
showMovementControls = false,
|
||
}: {
|
||
splatUrl?: string | null;
|
||
worldUrl?: string | null;
|
||
previewImageUrl?: string | null;
|
||
minimalFrame?: boolean;
|
||
firstPerson?: boolean;
|
||
hotspots?: World3DHotspot[];
|
||
interactionMode?: "navigate" | "hotspot";
|
||
onPick?: (pick: World3DPickResult) => void;
|
||
onHotspotSelect?: (hotspotId: string) => void;
|
||
semanticsMetadata?: Record<string, unknown> | null;
|
||
resetViewToken?: number;
|
||
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
||
showInfoOverlay?: boolean;
|
||
showMovementControls?: boolean;
|
||
}) {
|
||
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
|
||
const [overlayPhase, setOverlayPhase] = useState<SplatLoadingPhase>("loading");
|
||
const resolvedSplatUrl = useMemo(() => buildThreeAssetUrl(splatUrl), [splatUrl]);
|
||
const loadStartedAtRef = useRef(0);
|
||
const movementStateRef = useRef<MovementState>(createMovementState());
|
||
|
||
useEffect(() => {
|
||
loadStartedAtRef.current = performance.now();
|
||
setLoadState("loading");
|
||
setOverlayPhase("loading");
|
||
}, [resolvedSplatUrl]);
|
||
|
||
useEffect(() => {
|
||
if (!showMovementControls) {
|
||
movementStateRef.current = createMovementState();
|
||
}
|
||
}, [showMovementControls]);
|
||
|
||
useEffect(() => {
|
||
if (loadState === "ready") {
|
||
const elapsed = performance.now() - loadStartedAtRef.current;
|
||
setOverlayPhase(
|
||
elapsed <= SPLAT_FAST_LOAD_SKIP_REVEAL_MS ? "hidden" : "revealing"
|
||
);
|
||
}
|
||
|
||
if (loadState === "loading") {
|
||
setOverlayPhase("loading");
|
||
}
|
||
|
||
if (loadState === "error") {
|
||
setOverlayPhase("hidden");
|
||
}
|
||
}, [loadState]);
|
||
|
||
if (!resolvedSplatUrl) {
|
||
return (
|
||
<WorldEmptyState
|
||
description={
|
||
worldUrl
|
||
? "官方在线预览页不能稳定嵌入画布,正在等待可本地渲染的 World 资产。"
|
||
: "当前结果没有返回可用的世界数据。"
|
||
}
|
||
fillFrame={minimalFrame}
|
||
icon={Sparkles}
|
||
previewImageUrl={previewImageUrl}
|
||
title="准备加载 World 视图"
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (loadState === "error") {
|
||
return (
|
||
<WorldEmptyState
|
||
description={
|
||
worldUrl
|
||
? "World 资产加载失败,已避免退回不稳定的官方 iframe。可以切换到图像或 Mesh 视图继续查看。"
|
||
: "当前世界数据暂时无法加载,可以切换到图像或 Mesh 视图继续查看。"
|
||
}
|
||
fillFrame={minimalFrame}
|
||
icon={AlertCircle}
|
||
previewImageUrl={previewImageUrl}
|
||
title="World 视图加载失败"
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="relative h-full w-full">
|
||
<Canvas
|
||
camera={{ position: [0, 1.6, 4], fov: 55 }}
|
||
className="h-full w-full bg-[radial-gradient(circle_at_top,_rgba(15,23,42,0.95),_rgba(1,1,1,1)_68%)]"
|
||
dpr={minimalFrame ? [1, 1.5] : [1, 2]}
|
||
frameloop="always"
|
||
gl={{
|
||
antialias: true,
|
||
powerPreference: "high-performance",
|
||
preserveDrawingBuffer: Boolean(onCaptureReady),
|
||
}}
|
||
>
|
||
<WorldSplatCaptureBridge onCaptureReady={onCaptureReady} />
|
||
<WorldSplatScene
|
||
firstPerson={firstPerson}
|
||
hotspots={hotspots}
|
||
interactionMode={interactionMode}
|
||
minimalFrame={minimalFrame}
|
||
onHotspotSelect={onHotspotSelect}
|
||
onLoadStateChange={setLoadState}
|
||
onPick={onPick}
|
||
onRevealComplete={() => setOverlayPhase("hidden")}
|
||
revealPhase={overlayPhase}
|
||
resetViewToken={resetViewToken}
|
||
semanticsMetadata={semanticsMetadata}
|
||
showInfoOverlay={showInfoOverlay}
|
||
splatUrl={resolvedSplatUrl}
|
||
movementStateRef={movementStateRef}
|
||
movementEnabled={showMovementControls}
|
||
/>
|
||
</Canvas>
|
||
{showMovementControls && loadState === "ready" ? (
|
||
<WorldMovementPad movementStateRef={movementStateRef} />
|
||
) : null}
|
||
{overlayPhase !== "hidden" ? (
|
||
<WorldSplatLoadingOverlay phase={overlayPhase} previewImageUrl={previewImageUrl} />
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function World3DViewer({
|
||
mode,
|
||
worldUrl,
|
||
imageUrl,
|
||
meshUrl,
|
||
splatUrl,
|
||
previewImageUrl,
|
||
emptyStateTitle = "准备生成 3D 世界",
|
||
minimalFrame = false,
|
||
meshSettings,
|
||
meshLayerVisibility,
|
||
onMeshLayersChange,
|
||
onMeshStatsChange,
|
||
firstPerson = false,
|
||
hotspots = [],
|
||
interactionMode = "navigate",
|
||
onPick,
|
||
onHotspotSelect,
|
||
semanticsMetadata,
|
||
className,
|
||
resetViewToken,
|
||
onCaptureReady,
|
||
showMovementControls = false,
|
||
}: World3DViewerProps) {
|
||
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
|
||
|
||
useEffect(() => {
|
||
if (mode === "splat" || mode === "mesh") return;
|
||
onCaptureReady?.(null);
|
||
}, [mode, onCaptureReady]);
|
||
|
||
if (mode === "mesh") {
|
||
return (
|
||
<div className={cn("h-full w-full", className)}>
|
||
<Model3DViewer
|
||
emptyStateTitle={emptyStateTitle}
|
||
layerVisibility={meshLayerVisibility}
|
||
modelRotation={[Math.PI, 0, 0]}
|
||
modelUrl={meshUrl}
|
||
onLayersChange={onMeshLayersChange}
|
||
onStatsChange={onMeshStatsChange}
|
||
onCaptureReady={onCaptureReady}
|
||
previewImageUrl={previewImageUrl}
|
||
resetViewToken={resetViewToken}
|
||
settings={meshSettings || DEFAULT_MESH_SETTINGS}
|
||
showMovementControls={showMovementControls}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (mode === "world") {
|
||
return (
|
||
<div className={cn("h-full w-full overflow-hidden bg-zinc-950", className)}>
|
||
{worldUrl ? (
|
||
<iframe
|
||
allow="accelerometer; autoplay; clipboard-read; clipboard-write; fullscreen; gyroscope; xr-spatial-tracking"
|
||
allowFullScreen
|
||
className="h-full w-full border-0"
|
||
referrerPolicy="no-referrer-when-downgrade"
|
||
src={worldUrl}
|
||
title="3D 世界在线预览"
|
||
/>
|
||
) : (
|
||
<WorldEmptyState
|
||
description="当前结果还没有可嵌入的在线预览入口,可以先切换到 World、图像或 Mesh 视图。"
|
||
fillFrame={minimalFrame}
|
||
icon={Globe}
|
||
previewImageUrl={previewImageUrl}
|
||
title={emptyStateTitle}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (mode === "image") {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
minimalFrame
|
||
? "flex h-full w-full items-center justify-center overflow-hidden bg-zinc-950"
|
||
: "flex h-full w-full items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_top,_rgba(39,39,42,0.95),_rgba(2,6,23,1)_68%)] p-6",
|
||
className
|
||
)}
|
||
>
|
||
{resolvedImageUrl ? (
|
||
<img
|
||
alt="世界图像预览"
|
||
className={
|
||
minimalFrame
|
||
? "h-full w-full object-cover"
|
||
: "max-h-full w-full rounded-2xl object-contain shadow-[0_30px_80px_rgba(0,0,0,0.45)]"
|
||
}
|
||
src={resolvedImageUrl}
|
||
/>
|
||
) : (
|
||
<WorldEmptyState
|
||
description="当前结果还没有可用的图像资产,可以先切换到 World 或 Mesh 视图。"
|
||
fillFrame={minimalFrame}
|
||
icon={ImageIcon}
|
||
previewImageUrl={previewImageUrl}
|
||
title={emptyStateTitle}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={cn("h-full w-full", className)}>
|
||
<WorldSplatViewer
|
||
firstPerson={firstPerson}
|
||
hotspots={hotspots}
|
||
interactionMode={interactionMode}
|
||
onCaptureReady={onCaptureReady}
|
||
onHotspotSelect={onHotspotSelect}
|
||
onPick={onPick}
|
||
previewImageUrl={previewImageUrl}
|
||
minimalFrame={minimalFrame}
|
||
resetViewToken={resetViewToken}
|
||
semanticsMetadata={semanticsMetadata}
|
||
showInfoOverlay={!minimalFrame}
|
||
showMovementControls={showMovementControls}
|
||
splatUrl={splatUrl}
|
||
worldUrl={worldUrl}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|