fix: stabilize world splat reveal loading
This commit is contained in:
parent
b6b14d739f
commit
cd1256d816
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user