fix: stabilize world splat reveal loading
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-06 02:08:28 +08:00
parent b6b14d739f
commit cd1256d816

View File

@ -38,6 +38,12 @@ type MovementCommand =
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;
@ -151,6 +157,7 @@ function lerpSparkRevealProfile(
}
const SPLAT_REVEAL_DURATION_MS = 2600;
const SPLAT_PARTICLE_SAMPLE_LIMIT = 5200;
const KEY_TO_MOVEMENT: Partial<Record<string, MovementCommand>> = {
KeyW: "forward",
@ -201,6 +208,67 @@ function getSemanticFacts(metadata?: Record<string, unknown> | null) {
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 totalSplats = Math.max(0, splatMesh.packedSplats?.numSplats || 0);
if (!totalSplats) return null;
const sampleStep = Math.max(1, Math.floor(totalSplats / sampleLimit));
const points: Array<{
center: THREE.Vector3;
color: THREE.Color;
opacity: number;
}> = [];
const warmWhite = new THREE.Color("#fff1dc");
splatMesh.forEachSplat((index, center, _scales, _quaternion, opacity, color) => {
if (points.length >= sampleLimit) return;
if (index % sampleStep !== 0) return;
if (opacity < 0.035) return;
points.push({
center: center.clone(),
color: color.clone(),
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 positionCameraInsideWorld({
camera,
controls,
@ -305,6 +373,124 @@ function WorldEmptyState({
);
}
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 (
<group rotation={[Math.PI, 0, 0]}>
<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>
</group>
);
}
function WorldSplatLoadingOverlay({
previewImageUrl,
phase,
@ -499,6 +685,7 @@ function WorldSplatScene({
onHotspotSelect,
semanticsMetadata,
onLoadStateChange,
onRevealComplete,
}: {
splatUrl: string;
revealPhase?: SplatLoadingPhase;
@ -509,11 +696,14 @@ function WorldSplatScene({
onHotspotSelect?: (hotspotId: string) => void;
semanticsMetadata?: Record<string, unknown> | null;
onLoadStateChange?: (state: "ready" | "error") => void;
onRevealComplete?: () => void;
}) {
const camera = useThree((state) => state.camera);
const renderer = useThree((state) => state.gl);
const orbitControlsRef = useRef<any>(null);
const hasAppliedInitialViewRef = useRef(false);
const revealCompletedRef = useRef(false);
const [particleCloudData, setParticleCloudData] = useState<SplatParticleCloudData | null>(null);
const semanticFacts = useMemo(
() => getSemanticFacts(semanticsMetadata),
[semanticsMetadata]
@ -538,6 +728,7 @@ function WorldSplatScene({
useEffect(() => {
sparkRenderer.frustumCulled = false;
sparkRenderer.renderOrder = 10;
sparkRenderer.originDistance = 0.12;
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
return () => {
sparkRenderer.removeFromParent();
@ -548,11 +739,16 @@ function WorldSplatScene({
if (revealPhaseRef.current !== revealPhase) {
revealPhaseRef.current = revealPhase;
revealStartedAtRef.current = performance.now();
if (revealPhase === "loading") {
revealCompletedRef.current = false;
}
}
}, [revealPhase]);
useEffect(() => {
hasAppliedInitialViewRef.current = false;
revealCompletedRef.current = false;
setParticleCloudData(null);
splatMesh.frustumCulled = false;
splatMesh.rotation.x = Math.PI;
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE);
@ -563,6 +759,7 @@ function WorldSplatScene({
if (cancelled) return;
window.requestAnimationFrame(() => {
if (cancelled || hasAppliedInitialViewRef.current) return;
setParticleCloudData(buildSplatParticleCloudData(splatMesh));
positionCameraInsideWorld({
camera,
controls: orbitControlsRef.current,
@ -583,6 +780,9 @@ function WorldSplatScene({
}, [camera, groundPlaneOffset, onLoadStateChange, sparkRenderer, splatMesh]);
useFrame(() => {
sparkRenderer.position.copy(camera.position);
sparkRenderer.updateMatrixWorld();
const phase = revealPhaseRef.current;
const revealElapsed =
phase === "revealing"
@ -619,6 +819,10 @@ function WorldSplatScene({
);
}
applySparkRevealProfile(sparkRenderer, splatMesh, profile);
if (revealElapsed >= 1 && !revealCompletedRef.current) {
revealCompletedRef.current = true;
onRevealComplete?.();
}
} else {
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
}
@ -651,6 +855,7 @@ function WorldSplatScene({
<primitive object={splatMesh} onPointerDown={handlePointerDown} />
</primitive>
</Suspense>
{particleCloudData ? <SplatParticleCloud data={particleCloudData} phase={revealPhase} /> : null}
{hotspots.map((hotspot) => (
<HotspotBillboard
hotspot={hotspot}
@ -752,10 +957,6 @@ function WorldSplatViewer({
useEffect(() => {
if (loadState === "ready") {
setOverlayPhase("revealing");
const timer = window.setTimeout(() => {
setOverlayPhase("hidden");
}, SPLAT_REVEAL_DURATION_MS);
return () => window.clearTimeout(timer);
}
if (loadState === "loading") {
@ -804,6 +1005,7 @@ function WorldSplatViewer({
onHotspotSelect={onHotspotSelect}
onLoadStateChange={setLoadState}
onPick={onPick}
onRevealComplete={() => setOverlayPhase("hidden")}
revealPhase={overlayPhase}
semanticsMetadata={semanticsMetadata}
splatUrl={splatUrl}