feat: add granular splat reveal transition
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 01:31:58 +08:00
parent 9281df46fe
commit 5363a62e0e

View File

@ -43,11 +43,14 @@ type MovementCommand =
type MovementState = Record<MovementCommand, boolean>;
type SplatLoadingPhase = "loading" | "revealing" | "hidden";
type SplatRevealCloudData = {
count: number;
colors: Float32Array;
startPositions: Float32Array;
targetPositions: Float32Array;
type SparkRevealProfile = {
blurAmount: number;
preBlurAmount: number;
minAlpha: number;
maxStdDev: number;
focalAdjustment: number;
falloff: number;
opacity: number;
};
type World3DViewerProps = {
@ -88,6 +91,70 @@ const EMPTY_MOVEMENT_STATE: MovementState = {
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.19,
maxStdDev: Math.sqrt(2.2),
focalAdjustment: 2.35,
falloff: 0.2,
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 KEY_TO_MOVEMENT: Partial<Record<string, MovementCommand>> = {
KeyW: "forward",
ArrowUp: "forward",
@ -174,230 +241,6 @@ function WorldEmptyState({
);
}
const REAL_SPLAT_REVEAL_SAMPLE_LIMIT = 3600;
function seededUnit(index: number, salt: number) {
const value = Math.sin(index * 12.9898 + salt * 78.233) * 43758.5453;
return value - Math.floor(value);
}
function buildSplatRevealCloudData(
splatMesh: SparkSplatMesh,
sampleLimit = REAL_SPLAT_REVEAL_SAMPLE_LIMIT
): SplatRevealCloudData | null {
const totalSplats = Math.max(0, splatMesh.packedSplats?.numSplats || 0);
if (!totalSplats) return null;
const sampleStep = Math.max(1, Math.floor(totalSplats / sampleLimit));
const sampled: Array<{
center: THREE.Vector3;
color: THREE.Color;
opacity: number;
}> = [];
const bounds = new THREE.Box3();
splatMesh.forEachSplat((index, center, _scales, _quaternion, opacity, color) => {
if (sampled.length >= sampleLimit) return;
if (opacity < 0.04) return;
if (index % sampleStep !== 0) return;
const target = center.clone();
bounds.expandByPoint(target);
sampled.push({
center: target,
color: color.clone(),
opacity,
});
});
if (!sampled.length) return null;
const sceneCenter = bounds.getCenter(new THREE.Vector3());
const sceneBoundsSize = bounds.getSize(new THREE.Vector3());
const sceneSize = sceneBoundsSize.length();
const sceneRadius = Math.max(sceneSize * 0.42, 1.6);
const white = new THREE.Color("#fff4de");
const colors = new Float32Array(sampled.length * 3);
const startPositions = new Float32Array(sampled.length * 3);
const targetPositions = new Float32Array(sampled.length * 3);
sampled.forEach((sample, index) => {
const stride = index * 3;
const direction = sample.center.clone().sub(sceneCenter);
if (direction.lengthSq() < 1e-4) {
direction.set(
seededUnit(index, 10) - 0.5,
seededUnit(index, 11) - 0.35,
seededUnit(index, 12) - 0.5
);
}
direction.normalize();
const swirlAxis = new THREE.Vector3(
seededUnit(index, 13) - 0.5,
seededUnit(index, 14) - 0.5,
seededUnit(index, 15) - 0.5
);
if (swirlAxis.lengthSq() < 1e-4) {
swirlAxis.set(0.3, 1, 0.2);
}
swirlAxis.normalize();
const tangent = new THREE.Vector3().crossVectors(direction, swirlAxis);
if (tangent.lengthSq() < 1e-4) {
tangent.set(1, 0, 0);
} else {
tangent.normalize();
}
const start = sample.center
.clone()
.sub(sceneCenter)
.multiplyScalar(1.08 + seededUnit(index, 16) * 0.38)
.add(sceneCenter)
.addScaledVector(
tangent,
sceneRadius * (0.018 + seededUnit(index, 17) * 0.085)
)
.addScaledVector(
direction,
sceneRadius * (0.035 + seededUnit(index, 18) * 0.12)
)
.add(
new THREE.Vector3(
0,
sceneBoundsSize.y * (seededUnit(index, 19) - 0.5) * 0.16,
0
)
);
const displayColor = sample.color
.clone()
.lerp(white, 0.42)
.multiplyScalar(0.88 + Math.min(sample.opacity, 1) * 0.24);
targetPositions[stride] = sample.center.x;
targetPositions[stride + 1] = sample.center.y;
targetPositions[stride + 2] = sample.center.z;
startPositions[stride] = start.x;
startPositions[stride + 1] = start.y;
startPositions[stride + 2] = start.z;
colors[stride] = displayColor.r;
colors[stride + 1] = displayColor.g;
colors[stride + 2] = displayColor.b;
});
return {
count: sampled.length,
colors,
startPositions,
targetPositions,
};
}
function SplatRevealCloud({
data,
phase,
}: {
data: SplatRevealCloudData;
phase: Exclude<SplatLoadingPhase, "hidden">;
}) {
const pointsRef = useRef<THREE.Points>(null);
const materialRef = useRef<THREE.PointsMaterial>(null);
const positionBuffer = useMemo(
() => data.startPositions.slice(),
[data.startPositions]
);
const geometryRef = useRef<THREE.BufferGeometry>(null);
const phaseRef = useRef(phase);
const phaseStartedAtRef = useRef(performance.now());
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 elapsed = state.clock.getElapsedTime();
const rawProgress = Math.min(
(performance.now() - phaseStartedAtRef.current) / 1450,
1
);
const easedProgress = 1 - Math.pow(1 - rawProgress, 3);
const positionAttribute = geometry.getAttribute("position");
const positions = positionAttribute.array as Float32Array;
for (let index = 0; index < data.count; index += 1) {
const stride = index * 3;
const swirl =
Math.sin(elapsed * 2.4 + index * 0.17) * (1 - easedProgress) * 0.018;
positions[stride] =
THREE.MathUtils.lerp(
data.startPositions[stride],
data.targetPositions[stride],
easedProgress
) +
swirl;
positions[stride + 1] =
THREE.MathUtils.lerp(
data.startPositions[stride + 1],
data.targetPositions[stride + 1],
easedProgress
) +
swirl * 0.45;
positions[stride + 2] =
THREE.MathUtils.lerp(
data.startPositions[stride + 2],
data.targetPositions[stride + 2],
easedProgress
) -
swirl * 0.65;
}
positionAttribute.needsUpdate = true;
material.opacity = THREE.MathUtils.lerp(0.9, 0, easedProgress);
material.size = THREE.MathUtils.lerp(0.04, 0.009, easedProgress);
});
return (
<group rotation={[Math.PI, 0, 0]}>
<points
frustumCulled={false}
ref={pointsRef}
renderOrder={2}
>
<bufferGeometry ref={geometryRef}>
<bufferAttribute attach="attributes-position" args={[positionBuffer, 3]} />
<bufferAttribute attach="attributes-color" args={[data.colors, 3]} />
</bufferGeometry>
<pointsMaterial
blending={THREE.AdditiveBlending}
depthTest={false}
depthWrite={false}
ref={materialRef}
size={0.04}
sizeAttenuation
toneMapped={false}
transparent
vertexColors
/>
</points>
</group>
);
}
function WorldSplatLoadingOverlay({
previewImageUrl,
phase,
@ -408,49 +251,37 @@ function WorldSplatLoadingOverlay({
const isRevealing = phase === "revealing";
return (
<div
className={cn(
"pointer-events-none absolute inset-0 overflow-hidden transition-all duration-500",
isRevealing ? "opacity-0" : "opacity-100"
)}
>
{previewImageUrl ? (
<img
alt="世界预览"
className="absolute inset-0 h-full w-full object-cover opacity-[0.16] blur-[8px] saturate-[0.7]"
src={previewImageUrl}
/>
<>
{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]"
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}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_rgba(255,245,220,0.08)_0%,_rgba(11,15,25,0.18)_18%,_rgba(2,6,23,0.72)_54%,_rgba(1,2,6,0.95)_100%)]" />
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(5,10,20,0.16)_0%,rgba(5,10,20,0.78)_100%)]" />
<div className="absolute inset-0 bg-[linear-gradient(90deg,transparent_0%,rgba(255,248,220,0.045)_48%,transparent_58%)] [background-size:220%_100%] animate-[worldlabs-scan_3.1s_ease-in-out_infinite]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_52%,transparent_0%,transparent_28%,rgba(1,2,6,0.12)_58%,rgba(1,2,6,0.68)_100%)]" />
<div className="absolute inset-x-0 bottom-0 h-48 bg-[linear-gradient(180deg,rgba(2,6,23,0)_0%,rgba(2,6,23,0.92)_100%)]" />
<div className="absolute bottom-5 left-5 flex items-end gap-3">
<div
className={cn(
"pointer-events-none absolute bottom-5 left-5 transition-all duration-300 opacity-100"
)}
>
<div className="rounded-2xl border border-white/10 bg-black/45 px-3 py-2 shadow-[0_20px_60px_rgba(0,0,0,0.38)] backdrop-blur-xl">
<div className="flex items-center gap-2 text-[11px] text-zinc-100">
<Sparkles className="h-3.5 w-3.5 text-cyan-200" />
<span>{isRevealing ? "真实点云正在成形" : "正在载入真实 Gaussian Splats"}</span>
<span>{isRevealing ? "真实世界颗粒正在聚合" : "正在载入真实 Gaussian Splats"}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-zinc-400">
<Loader2 className="h-3 w-3 animate-spin" />
<span>{isRevealing ? "点云聚合中" : "SPZ 数据准备中"}</span>
<span>{isRevealing ? "真实 splat 正在从颗粒态恢复成世界" : "SPZ 数据准备中"}</span>
</div>
</div>
</div>
<style jsx>{`
@keyframes worldlabs-scan {
0% {
background-position: 120% 0;
}
100% {
background-position: -20% 0;
}
}
`}</style>
</div>
</>
);
}
@ -694,8 +525,6 @@ function WorldSplatScene({
const orbitControlsRef = useRef<any>(null);
const [virtualMovement, setVirtualMovement] =
useState<MovementState>(createMovementState);
const [revealCloudData, setRevealCloudData] =
useState<SplatRevealCloudData | null>(null);
const semanticFacts = useMemo(
() => getSemanticFacts(semanticsMetadata),
[semanticsMetadata]
@ -716,12 +545,11 @@ function WorldSplatScene({
useEffect(() => {
sparkRenderer.frustumCulled = false;
sparkRenderer.renderOrder = 10;
sparkRenderer.minAlpha = 0.02;
sparkRenderer.preBlurAmount = 0.04;
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
return () => {
sparkRenderer.removeFromParent();
};
}, [sparkRenderer]);
}, [sparkRenderer, splatMesh]);
useEffect(() => {
if (revealPhaseRef.current !== revealPhase) {
@ -733,18 +561,12 @@ function WorldSplatScene({
useEffect(() => {
splatMesh.frustumCulled = false;
splatMesh.rotation.x = Math.PI;
splatMesh.opacity = 0;
sparkRenderer.blurAmount = 0.95;
sparkRenderer.preBlurAmount = 0.28;
sparkRenderer.minAlpha = 0.08;
sparkRenderer.maxStdDev = Math.sqrt(10);
setRevealCloudData(null);
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE);
let cancelled = false;
splatMesh.initialized
.then(() => {
if (cancelled) return;
setRevealCloudData(buildSplatRevealCloudData(splatMesh));
onLoadStateChange?.("ready");
})
.catch(() => {
@ -760,32 +582,26 @@ function WorldSplatScene({
const phase = revealPhaseRef.current;
const revealElapsed =
phase === "revealing"
? Math.min((performance.now() - revealStartedAtRef.current) / 1250, 1)
? Math.min((performance.now() - revealStartedAtRef.current) / 1650, 1)
: 0;
const easedReveal = 1 - Math.pow(1 - revealElapsed, 3);
if (phase === "loading") {
splatMesh.opacity = 0;
sparkRenderer.blurAmount = 0.95;
sparkRenderer.preBlurAmount = 0.28;
sparkRenderer.minAlpha = 0.08;
sparkRenderer.maxStdDev = Math.sqrt(10);
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE);
} else if (phase === "revealing") {
splatMesh.opacity = THREE.MathUtils.lerp(0.02, 1, easedReveal);
sparkRenderer.blurAmount = THREE.MathUtils.lerp(0.95, 0.16, easedReveal);
sparkRenderer.preBlurAmount = THREE.MathUtils.lerp(0.28, 0.04, easedReveal);
sparkRenderer.minAlpha = THREE.MathUtils.lerp(0.08, 0.02, easedReveal);
sparkRenderer.maxStdDev = THREE.MathUtils.lerp(
Math.sqrt(10),
Math.sqrt(7),
easedReveal
const particleHold = 0.32;
const granularProgress =
easedReveal <= particleHold
? 0
: (easedReveal - particleHold) / (1 - particleHold);
const profile = lerpSparkRevealProfile(
SPLAT_PARTICLE_PROFILE,
SPLAT_NORMAL_PROFILE,
THREE.MathUtils.smoothstep(granularProgress, 0, 1)
);
applySparkRevealProfile(sparkRenderer, splatMesh, profile);
} else {
splatMesh.opacity = 1;
sparkRenderer.blurAmount = 0.16;
sparkRenderer.preBlurAmount = 0.04;
sparkRenderer.minAlpha = 0.02;
sparkRenderer.maxStdDev = Math.sqrt(7);
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
}
sparkRenderer.needsUpdate = true;
@ -826,9 +642,6 @@ function WorldSplatScene({
position={[0, groundPlaneOffset, 0]}
/>
) : null}
{revealPhase !== "hidden" && revealCloudData ? (
<SplatRevealCloud data={revealCloudData} phase={revealPhase} />
) : null}
<Suspense fallback={null}>
<primitive object={sparkRenderer}>
<primitive object={splatMesh} onPointerDown={handlePointerDown} />
@ -944,7 +757,7 @@ function WorldSplatViewer({
setOverlayPhase("revealing");
const timer = window.setTimeout(() => {
setOverlayPhase("hidden");
}, 1250);
}, 1650);
return () => window.clearTimeout(timer);
}