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 MovementState = Record<MovementCommand, boolean>;
|
||||||
type SplatLoadingPhase = "loading" | "revealing" | "hidden";
|
type SplatLoadingPhase = "loading" | "revealing" | "hidden";
|
||||||
|
type SplatParticleCloudData = {
|
||||||
|
count: number;
|
||||||
|
colors: Float32Array;
|
||||||
|
positions: Float32Array;
|
||||||
|
seeds: Float32Array;
|
||||||
|
};
|
||||||
type SparkRevealProfile = {
|
type SparkRevealProfile = {
|
||||||
blurAmount: number;
|
blurAmount: number;
|
||||||
preBlurAmount: number;
|
preBlurAmount: number;
|
||||||
@ -151,6 +157,7 @@ function lerpSparkRevealProfile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SPLAT_REVEAL_DURATION_MS = 2600;
|
const SPLAT_REVEAL_DURATION_MS = 2600;
|
||||||
|
const SPLAT_PARTICLE_SAMPLE_LIMIT = 5200;
|
||||||
|
|
||||||
const KEY_TO_MOVEMENT: Partial<Record<string, MovementCommand>> = {
|
const KEY_TO_MOVEMENT: Partial<Record<string, MovementCommand>> = {
|
||||||
KeyW: "forward",
|
KeyW: "forward",
|
||||||
@ -201,6 +208,67 @@ function getSemanticFacts(metadata?: Record<string, unknown> | null) {
|
|||||||
return facts;
|
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({
|
function positionCameraInsideWorld({
|
||||||
camera,
|
camera,
|
||||||
controls,
|
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({
|
function WorldSplatLoadingOverlay({
|
||||||
previewImageUrl,
|
previewImageUrl,
|
||||||
phase,
|
phase,
|
||||||
@ -499,6 +685,7 @@ function WorldSplatScene({
|
|||||||
onHotspotSelect,
|
onHotspotSelect,
|
||||||
semanticsMetadata,
|
semanticsMetadata,
|
||||||
onLoadStateChange,
|
onLoadStateChange,
|
||||||
|
onRevealComplete,
|
||||||
}: {
|
}: {
|
||||||
splatUrl: string;
|
splatUrl: string;
|
||||||
revealPhase?: SplatLoadingPhase;
|
revealPhase?: SplatLoadingPhase;
|
||||||
@ -509,11 +696,14 @@ function WorldSplatScene({
|
|||||||
onHotspotSelect?: (hotspotId: string) => void;
|
onHotspotSelect?: (hotspotId: string) => void;
|
||||||
semanticsMetadata?: Record<string, unknown> | null;
|
semanticsMetadata?: Record<string, unknown> | null;
|
||||||
onLoadStateChange?: (state: "ready" | "error") => void;
|
onLoadStateChange?: (state: "ready" | "error") => void;
|
||||||
|
onRevealComplete?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
const renderer = useThree((state) => state.gl);
|
const renderer = useThree((state) => state.gl);
|
||||||
const orbitControlsRef = useRef<any>(null);
|
const orbitControlsRef = useRef<any>(null);
|
||||||
const hasAppliedInitialViewRef = useRef(false);
|
const hasAppliedInitialViewRef = useRef(false);
|
||||||
|
const revealCompletedRef = useRef(false);
|
||||||
|
const [particleCloudData, setParticleCloudData] = useState<SplatParticleCloudData | null>(null);
|
||||||
const semanticFacts = useMemo(
|
const semanticFacts = useMemo(
|
||||||
() => getSemanticFacts(semanticsMetadata),
|
() => getSemanticFacts(semanticsMetadata),
|
||||||
[semanticsMetadata]
|
[semanticsMetadata]
|
||||||
@ -538,6 +728,7 @@ function WorldSplatScene({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sparkRenderer.frustumCulled = false;
|
sparkRenderer.frustumCulled = false;
|
||||||
sparkRenderer.renderOrder = 10;
|
sparkRenderer.renderOrder = 10;
|
||||||
|
sparkRenderer.originDistance = 0.12;
|
||||||
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
|
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
|
||||||
return () => {
|
return () => {
|
||||||
sparkRenderer.removeFromParent();
|
sparkRenderer.removeFromParent();
|
||||||
@ -548,11 +739,16 @@ function WorldSplatScene({
|
|||||||
if (revealPhaseRef.current !== revealPhase) {
|
if (revealPhaseRef.current !== revealPhase) {
|
||||||
revealPhaseRef.current = revealPhase;
|
revealPhaseRef.current = revealPhase;
|
||||||
revealStartedAtRef.current = performance.now();
|
revealStartedAtRef.current = performance.now();
|
||||||
|
if (revealPhase === "loading") {
|
||||||
|
revealCompletedRef.current = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [revealPhase]);
|
}, [revealPhase]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasAppliedInitialViewRef.current = false;
|
hasAppliedInitialViewRef.current = false;
|
||||||
|
revealCompletedRef.current = false;
|
||||||
|
setParticleCloudData(null);
|
||||||
splatMesh.frustumCulled = false;
|
splatMesh.frustumCulled = false;
|
||||||
splatMesh.rotation.x = Math.PI;
|
splatMesh.rotation.x = Math.PI;
|
||||||
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE);
|
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE);
|
||||||
@ -563,6 +759,7 @@ function WorldSplatScene({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
if (cancelled || hasAppliedInitialViewRef.current) return;
|
if (cancelled || hasAppliedInitialViewRef.current) return;
|
||||||
|
setParticleCloudData(buildSplatParticleCloudData(splatMesh));
|
||||||
positionCameraInsideWorld({
|
positionCameraInsideWorld({
|
||||||
camera,
|
camera,
|
||||||
controls: orbitControlsRef.current,
|
controls: orbitControlsRef.current,
|
||||||
@ -583,6 +780,9 @@ function WorldSplatScene({
|
|||||||
}, [camera, groundPlaneOffset, onLoadStateChange, sparkRenderer, splatMesh]);
|
}, [camera, groundPlaneOffset, onLoadStateChange, sparkRenderer, splatMesh]);
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
|
sparkRenderer.position.copy(camera.position);
|
||||||
|
sparkRenderer.updateMatrixWorld();
|
||||||
|
|
||||||
const phase = revealPhaseRef.current;
|
const phase = revealPhaseRef.current;
|
||||||
const revealElapsed =
|
const revealElapsed =
|
||||||
phase === "revealing"
|
phase === "revealing"
|
||||||
@ -619,6 +819,10 @@ function WorldSplatScene({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
applySparkRevealProfile(sparkRenderer, splatMesh, profile);
|
applySparkRevealProfile(sparkRenderer, splatMesh, profile);
|
||||||
|
if (revealElapsed >= 1 && !revealCompletedRef.current) {
|
||||||
|
revealCompletedRef.current = true;
|
||||||
|
onRevealComplete?.();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
|
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
|
||||||
}
|
}
|
||||||
@ -651,6 +855,7 @@ function WorldSplatScene({
|
|||||||
<primitive object={splatMesh} onPointerDown={handlePointerDown} />
|
<primitive object={splatMesh} onPointerDown={handlePointerDown} />
|
||||||
</primitive>
|
</primitive>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
{particleCloudData ? <SplatParticleCloud data={particleCloudData} phase={revealPhase} /> : null}
|
||||||
{hotspots.map((hotspot) => (
|
{hotspots.map((hotspot) => (
|
||||||
<HotspotBillboard
|
<HotspotBillboard
|
||||||
hotspot={hotspot}
|
hotspot={hotspot}
|
||||||
@ -752,10 +957,6 @@ function WorldSplatViewer({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadState === "ready") {
|
if (loadState === "ready") {
|
||||||
setOverlayPhase("revealing");
|
setOverlayPhase("revealing");
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
setOverlayPhase("hidden");
|
|
||||||
}, SPLAT_REVEAL_DURATION_MS);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadState === "loading") {
|
if (loadState === "loading") {
|
||||||
@ -804,6 +1005,7 @@ function WorldSplatViewer({
|
|||||||
onHotspotSelect={onHotspotSelect}
|
onHotspotSelect={onHotspotSelect}
|
||||||
onLoadStateChange={setLoadState}
|
onLoadStateChange={setLoadState}
|
||||||
onPick={onPick}
|
onPick={onPick}
|
||||||
|
onRevealComplete={() => setOverlayPhase("hidden")}
|
||||||
revealPhase={overlayPhase}
|
revealPhase={overlayPhase}
|
||||||
semanticsMetadata={semanticsMetadata}
|
semanticsMetadata={semanticsMetadata}
|
||||||
splatUrl={splatUrl}
|
splatUrl={splatUrl}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user