feat: add 3d splat assembly loading effect
This commit is contained in:
parent
0d5a69e543
commit
62b5b75ec9
@ -42,6 +42,7 @@ type MovementCommand =
|
|||||||
| "down";
|
| "down";
|
||||||
|
|
||||||
type MovementState = Record<MovementCommand, boolean>;
|
type MovementState = Record<MovementCommand, boolean>;
|
||||||
|
type SplatLoadingPhase = "loading" | "fading" | "hidden";
|
||||||
|
|
||||||
type World3DViewerProps = {
|
type World3DViewerProps = {
|
||||||
mode: World3DViewMode;
|
mode: World3DViewMode;
|
||||||
@ -182,12 +183,158 @@ const SPLAT_LOADING_PARTICLES = [
|
|||||||
{ x: 12, y: 44, size: 4, dx: 12, dy: 14, delay: 1.8, duration: 5.3 },
|
{ x: 12, y: 44, size: 4, dx: 12, dy: 14, delay: 1.8, duration: 5.3 },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const SPLAT_ASSEMBLY_POINT_COUNT = 220;
|
||||||
|
const SPLAT_ASSEMBLY_COLORS = ["#67e8f9", "#93c5fd", "#f8fafc", "#22d3ee"] as const;
|
||||||
|
|
||||||
|
function seededUnit(index: number, salt: number) {
|
||||||
|
const value = Math.sin(index * 12.9898 + salt * 78.233) * 43758.5453;
|
||||||
|
return value - Math.floor(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SplatAssemblyCloud({
|
||||||
|
phase,
|
||||||
|
}: {
|
||||||
|
phase: Exclude<SplatLoadingPhase, "hidden">;
|
||||||
|
}) {
|
||||||
|
const pointsRef = useRef<THREE.Points>(null);
|
||||||
|
const materialRef = useRef<THREE.PointsMaterial>(null);
|
||||||
|
const geometryRef = useRef<THREE.BufferGeometry>(null);
|
||||||
|
const phaseRef = useRef(phase);
|
||||||
|
const phaseStartedAtRef = useRef(0);
|
||||||
|
|
||||||
|
const seeds = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: SPLAT_ASSEMBLY_POINT_COUNT }, (_, index) => {
|
||||||
|
const spread = seededUnit(index, 1);
|
||||||
|
const tilt = seededUnit(index, 2);
|
||||||
|
const orbit = seededUnit(index, 3);
|
||||||
|
const colorIndex = Math.floor(
|
||||||
|
seededUnit(index, 4) * SPLAT_ASSEMBLY_COLORS.length
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
target: new THREE.Vector3(
|
||||||
|
(spread - 0.5) * 4.8,
|
||||||
|
0.45 + tilt * 2.6,
|
||||||
|
(orbit - 0.5) * 4.4
|
||||||
|
),
|
||||||
|
orbitRadius: 0.4 + seededUnit(index, 5) * 2.2,
|
||||||
|
orbitSpeed: 0.2 + seededUnit(index, 6) * 0.8,
|
||||||
|
angle: seededUnit(index, 7) * Math.PI * 2,
|
||||||
|
verticalAmplitude: 0.08 + seededUnit(index, 8) * 0.32,
|
||||||
|
drift: 0.65 + seededUnit(index, 9) * 0.75,
|
||||||
|
color: new THREE.Color(
|
||||||
|
SPLAT_ASSEMBLY_COLORS[colorIndex] || SPLAT_ASSEMBLY_COLORS[0]
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const positions = useMemo(
|
||||||
|
() => new Float32Array(SPLAT_ASSEMBLY_POINT_COUNT * 3),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const colors = useMemo(() => {
|
||||||
|
const result = new Float32Array(SPLAT_ASSEMBLY_POINT_COUNT * 3);
|
||||||
|
seeds.forEach((seed, index) => {
|
||||||
|
result[index * 3] = seed.color.r;
|
||||||
|
result[index * 3 + 1] = seed.color.g;
|
||||||
|
result[index * 3 + 2] = seed.color.b;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, [seeds]);
|
||||||
|
|
||||||
|
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 fadeElapsed =
|
||||||
|
phaseRef.current === "fading"
|
||||||
|
? Math.min((performance.now() - phaseStartedAtRef.current) / 850, 1)
|
||||||
|
: 0;
|
||||||
|
const baseOpacity =
|
||||||
|
phaseRef.current === "loading"
|
||||||
|
? 0.78
|
||||||
|
: THREE.MathUtils.lerp(0.78, 0, fadeElapsed);
|
||||||
|
material.opacity = baseOpacity;
|
||||||
|
material.size = THREE.MathUtils.lerp(0.09, 0.02, fadeElapsed);
|
||||||
|
|
||||||
|
seeds.forEach((seed, index) => {
|
||||||
|
const stride = index * 3;
|
||||||
|
const orbitAngle = elapsed * seed.orbitSpeed + seed.angle;
|
||||||
|
const phaseMix =
|
||||||
|
phaseRef.current === "loading"
|
||||||
|
? 0.22 + 0.2 * Math.sin(elapsed * 0.65 + seed.angle)
|
||||||
|
: THREE.MathUtils.lerp(0.72, 1.02, fadeElapsed);
|
||||||
|
const orbitRadius =
|
||||||
|
seed.orbitRadius *
|
||||||
|
(phaseRef.current === "loading"
|
||||||
|
? 0.95 + 0.08 * Math.sin(elapsed * 1.3 + seed.angle)
|
||||||
|
: Math.max(0, 0.45 * (1 - fadeElapsed)));
|
||||||
|
const verticalWave =
|
||||||
|
Math.sin(elapsed * seed.drift + seed.angle * 1.4) *
|
||||||
|
seed.verticalAmplitude *
|
||||||
|
(phaseRef.current === "loading" ? 1 : 1 - fadeElapsed);
|
||||||
|
|
||||||
|
positions[stride] =
|
||||||
|
seed.target.x * phaseMix + Math.cos(orbitAngle) * orbitRadius;
|
||||||
|
positions[stride + 1] =
|
||||||
|
seed.target.y * phaseMix +
|
||||||
|
verticalWave +
|
||||||
|
Math.sin(orbitAngle * 0.7) * orbitRadius * 0.18;
|
||||||
|
positions[stride + 2] =
|
||||||
|
seed.target.z * phaseMix + Math.sin(orbitAngle) * orbitRadius;
|
||||||
|
});
|
||||||
|
|
||||||
|
geometry.attributes.position.needsUpdate = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<points frustumCulled={false} ref={pointsRef} renderOrder={2}>
|
||||||
|
<bufferGeometry ref={geometryRef}>
|
||||||
|
<bufferAttribute
|
||||||
|
attach="attributes-position"
|
||||||
|
args={[positions, 3]}
|
||||||
|
/>
|
||||||
|
<bufferAttribute
|
||||||
|
attach="attributes-color"
|
||||||
|
args={[colors, 3]}
|
||||||
|
/>
|
||||||
|
</bufferGeometry>
|
||||||
|
<pointsMaterial
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
depthWrite={false}
|
||||||
|
ref={materialRef}
|
||||||
|
size={0.09}
|
||||||
|
sizeAttenuation
|
||||||
|
transparent
|
||||||
|
vertexColors
|
||||||
|
/>
|
||||||
|
</points>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function WorldSplatLoadingOverlay({
|
function WorldSplatLoadingOverlay({
|
||||||
previewImageUrl,
|
previewImageUrl,
|
||||||
phase,
|
phase,
|
||||||
}: {
|
}: {
|
||||||
previewImageUrl?: string | null;
|
previewImageUrl?: string | null;
|
||||||
phase: "loading" | "fading";
|
phase: Exclude<SplatLoadingPhase, "hidden">;
|
||||||
}) {
|
}) {
|
||||||
const isFading = phase === "fading";
|
const isFading = phase === "fading";
|
||||||
|
|
||||||
@ -506,6 +653,7 @@ function HotspotBillboard({
|
|||||||
|
|
||||||
function WorldSplatScene({
|
function WorldSplatScene({
|
||||||
splatUrl,
|
splatUrl,
|
||||||
|
assemblyPhase = "loading",
|
||||||
firstPerson = false,
|
firstPerson = false,
|
||||||
hotspots = [],
|
hotspots = [],
|
||||||
interactionMode = "navigate",
|
interactionMode = "navigate",
|
||||||
@ -515,6 +663,7 @@ function WorldSplatScene({
|
|||||||
onLoadStateChange,
|
onLoadStateChange,
|
||||||
}: {
|
}: {
|
||||||
splatUrl: string;
|
splatUrl: string;
|
||||||
|
assemblyPhase?: SplatLoadingPhase;
|
||||||
firstPerson?: boolean;
|
firstPerson?: boolean;
|
||||||
hotspots?: World3DHotspot[];
|
hotspots?: World3DHotspot[];
|
||||||
interactionMode?: "navigate" | "hotspot";
|
interactionMode?: "navigate" | "hotspot";
|
||||||
@ -606,6 +755,9 @@ function WorldSplatScene({
|
|||||||
position={[0, groundPlaneOffset, 0]}
|
position={[0, groundPlaneOffset, 0]}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{assemblyPhase !== "hidden" ? (
|
||||||
|
<SplatAssemblyCloud phase={assemblyPhase} />
|
||||||
|
) : null}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<primitive object={sparkRenderer}>
|
<primitive object={sparkRenderer}>
|
||||||
<primitive object={splatMesh} onPointerDown={handlePointerDown} />
|
<primitive object={splatMesh} onPointerDown={handlePointerDown} />
|
||||||
@ -709,9 +861,7 @@ function WorldSplatViewer({
|
|||||||
semanticsMetadata?: Record<string, unknown> | null;
|
semanticsMetadata?: Record<string, unknown> | null;
|
||||||
}) {
|
}) {
|
||||||
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
|
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
|
||||||
const [overlayPhase, setOverlayPhase] = useState<"loading" | "fading" | "hidden">(
|
const [overlayPhase, setOverlayPhase] = useState<SplatLoadingPhase>("loading");
|
||||||
"loading"
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoadState("loading");
|
setLoadState("loading");
|
||||||
@ -767,6 +917,7 @@ function WorldSplatViewer({
|
|||||||
gl={{ antialias: false }}
|
gl={{ antialias: false }}
|
||||||
>
|
>
|
||||||
<WorldSplatScene
|
<WorldSplatScene
|
||||||
|
assemblyPhase={overlayPhase}
|
||||||
firstPerson={firstPerson}
|
firstPerson={firstPerson}
|
||||||
hotspots={hotspots}
|
hotspots={hotspots}
|
||||||
interactionMode={interactionMode}
|
interactionMode={interactionMode}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user