diff --git a/src/entities/scene/lib/scene-timing.ts b/src/entities/scene/lib/scene-timing.ts new file mode 100644 index 00000000..f82a69f5 --- /dev/null +++ b/src/entities/scene/lib/scene-timing.ts @@ -0,0 +1,42 @@ +const SCENE_TIMING_GLB_READY_PREFIX = 'scene:glb-ready:'; +const SCENE_TIMING_FIRST_FRAME_MARK = 'scene:first-frame'; +const SCENE_TIMING_FIRST_CONTENT_FRAME_MARK = 'scene:first-content-frame'; + +/** + * 같은 이름의 mark가 이미 있으면 다시 찍지 않습니다. + * Suspense는 같은 컴포넌트를 여러 번 평가하므로, 최초 시점만 남겨야 의미가 있습니다. + */ +const markOnce = (name: string): void => { + if (typeof performance === 'undefined') return; + if (performance.getEntriesByName(name, 'mark').length > 0) return; + + performance.mark(name); +}; + +/** + * GLB 하나가 파싱과 KTX2 transcode를 마쳐 사용 가능해진 시점을 기록합니다. + * + * 다운로드 구간은 브라우저의 Resource Timing에 이미 있으므로 따로 재지 않습니다. + * 여기서 재야 하는 것은 **바이트가 도착한 뒤에 일어나는 일**뿐입니다. + */ +export const markSceneGlbReady = (path: string): void => { + markOnce(`${SCENE_TIMING_GLB_READY_PREFIX}${path}`); +}; + +/** + * 캔버스가 살아나 첫 프레임을 그린 시점을 기록합니다. 이때 씬은 아직 비어 있습니다. + */ +export const markSceneFirstFrame = (): void => { + markOnce(SCENE_TIMING_FIRST_FRAME_MARK); +}; + +/** + * 씬 내용(캐릭터·소품)이 실제로 그려진 첫 프레임을 기록합니다. + * + * {@link markSceneFirstFrame}과 반드시 나눠야 합니다 — ``는 GLB를 기다리지 않고 + * 빈 씬으로 먼저 그려지므로(실측 약 430ms 앞섬), 그 시점을 "첫 프레임"으로 쓰면 + * 로딩 비용 전체가 숫자에서 사라집니다. + */ +export const markSceneFirstContentFrame = (): void => { + markOnce(SCENE_TIMING_FIRST_CONTENT_FRAME_MARK); +}; diff --git a/src/entities/scene/model/use-scene-gltf.ts b/src/entities/scene/model/use-scene-gltf.ts index 77996ff6..1c44529c 100644 --- a/src/entities/scene/model/use-scene-gltf.ts +++ b/src/entities/scene/model/use-scene-gltf.ts @@ -3,6 +3,7 @@ import { useGLTF } from '@react-three/drei'; import { useThree } from '@react-three/fiber'; +import { markSceneGlbReady } from '@/entities/scene/lib/scene-timing'; import { resolveKtx2Loader } from '@/shared/lib/three/ktx2-loader'; // GLB는 전부 Meshopt로 압축되어 Draco 디코더가 실제로 쓰이진 않지만, @@ -23,10 +24,15 @@ type StdlibKtx2Loader = Parameters[0]['setKTX2Loade export const useSceneGltf = (path: string): SceneGltf => { const gl = useThree(state => state.gl); - return useGLTF(path, true, true, loader => { + const gltf = useGLTF(path, true, true, loader => { // drei의 GLTFLoader는 three-stdlib 것이라 KTX2Loader 타입도 three-stdlib 선언을 요구한다. // three-stdlib은 직접 의존성이 아니므로(drei의 전이 의존) three 본체의 KTX2Loader를 쓰고, // 구조만 같으면 되는 자리라 여기서 한 번만 좁혀 준다. loader.setKTX2Loader(resolveKtx2Loader(gl) as unknown as StdlibKtx2Loader); }); + + // Suspense가 풀린 뒤 이 줄에 처음 도달한 시점이 곧 "파싱·transcode가 끝난 시점"이다. + markSceneGlbReady(path); + + return gltf; }; diff --git a/src/entities/scene/ui/scene-timing-probe.test.tsx b/src/entities/scene/ui/scene-timing-probe.test.tsx new file mode 100644 index 00000000..591ec49d --- /dev/null +++ b/src/entities/scene/ui/scene-timing-probe.test.tsx @@ -0,0 +1,60 @@ +import { render } from '@testing-library/react'; +import React from 'react'; + +import { SceneTimingProbe } from '@/entities/scene/ui/scene-timing-probe'; + +const frameCallbacks: (() => void)[] = []; + +vi.mock('@react-three/fiber', () => ({ + useFrame: (callback: () => void) => { + frameCallbacks.push(callback); + }, +})); + +/** 렌더 루프가 도는 것을 흉내 낸다. jsdom에는 실제 프레임이 없다. */ +const runFrames = (count: number) => { + for (let index = 0; index < count; index += 1) { + frameCallbacks.forEach(callback => { + callback(); + }); + } +}; + +const markNames = () => + performance + .getEntriesByType('mark') + .map(entry => entry.name) + .filter(name => name.startsWith('scene:')); + +describe('SceneTimingProbe', () => { + beforeEach(() => { + frameCallbacks.length = 0; + performance.clearMarks(); + }); + + it('프레임이 여러 번 돌아도 첫 프레임 mark를 한 번만 남긴다', () => { + render(); + runFrames(3); + + expect(markNames()).toEqual(['scene:first-frame']); + }); + + it('isContent면 씬 내용이 그려진 프레임으로 기록한다', () => { + render(); + runFrames(3); + + expect(markNames()).toEqual(['scene:first-content-frame']); + }); + + it('두 프로브가 함께 있으면 빈 캔버스와 씬 내용 시점을 각각 남긴다', () => { + render( + <> + + + , + ); + runFrames(2); + + expect(markNames().sort()).toEqual(['scene:first-content-frame', 'scene:first-frame']); + }); +}); diff --git a/src/entities/scene/ui/scene-timing-probe.tsx b/src/entities/scene/ui/scene-timing-probe.tsx new file mode 100644 index 00000000..56ee8879 --- /dev/null +++ b/src/entities/scene/ui/scene-timing-probe.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useFrame } from '@react-three/fiber'; +import { useRef } from 'react'; + +import { markSceneFirstContentFrame, markSceneFirstFrame } from '@/entities/scene/lib/scene-timing'; + +type SceneTimingProbeProps = { + /** + * `true`면 씬 내용이 그려진 첫 프레임으로 기록합니다. + * Suspense 안쪽(GLB가 준비된 뒤 마운트되는 자리)에 둔 인스턴스만 이 값을 켭니다. + */ + readonly isContent?: boolean; +}; + +/** + * 씬이 처음 렌더된 프레임에 `performance.mark`를 남깁니다. 화면에는 아무것도 그리지 않습니다. + * + * `` 안에 있어야 합니다 — `useFrame`은 실제로 그려지는 프레임에서만 돌기 때문에 + * "로드가 끝난 시점"이 아니라 "화면에 나온 시점"을 잡을 수 있습니다. + * 남긴 mark는 DevTools Performance 패널 타임라인에 그대로 표시되므로 별도 집계는 두지 않습니다. + */ +export const SceneTimingProbe = ({ isContent = false }: SceneTimingProbeProps) => { + const hasMarkedRef = useRef(false); + + useFrame(() => { + if (hasMarkedRef.current) return; + + hasMarkedRef.current = true; + + if (isContent) { + markSceneFirstContentFrame(); + + return; + } + + markSceneFirstFrame(); + }); + + return null; +}; diff --git a/src/widgets/home-hero-scene/ui/home-hero-stage-canvas.test.tsx b/src/widgets/home-hero-scene/ui/home-hero-stage-canvas.test.tsx index 6426df52..ec719209 100644 --- a/src/widgets/home-hero-scene/ui/home-hero-stage-canvas.test.tsx +++ b/src/widgets/home-hero-scene/ui/home-hero-stage-canvas.test.tsx @@ -85,6 +85,8 @@ vi.mock('@react-three/fiber', () => ({ ); }, + // SceneTimingProbe가 첫 프레임을 기록하는 데 쓴다. jsdom에는 렌더 루프가 없으므로 no-op. + useFrame: () => undefined, })); vi.mock('@react-three/drei', () => ({ diff --git a/src/widgets/home-hero-scene/ui/home-hero-stage-canvas.tsx b/src/widgets/home-hero-scene/ui/home-hero-stage-canvas.tsx index 0f18c3f5..8c63a35d 100644 --- a/src/widgets/home-hero-scene/ui/home-hero-stage-canvas.tsx +++ b/src/widgets/home-hero-scene/ui/home-hero-stage-canvas.tsx @@ -9,6 +9,7 @@ import { css, cx } from 'styled-system/css'; import type { SceneBreakpoint, SceneViewportMode } from '@/entities/scene/model/breakpointConfig'; import { BASS_MODEL_PATH, TABLE_MODEL_PATH } from '@/entities/scene/model/preloadGLB'; import { SceneProp } from '@/entities/scene/ui/scene-prop'; +import { SceneTimingProbe } from '@/entities/scene/ui/scene-timing-probe'; import { useBassAudio } from '@/features/audio/model/use-bass-audio'; import { scrollHomeHeroToProjects } from '@/features/interaction/model/scroll-home-hero-to-projects'; import { SceneInteractionController } from '@/features/interaction/ui/scene-interaction-controller'; @@ -151,6 +152,7 @@ export const HomeHeroStageCanvas = ({ }} > + +