Repository navigation
캐릭터 애니메이션 2배속 정상화 - #97
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. Walkthrough
Changes캐릭터 믹서 프레임 갱신
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to The change limits each character mixer to updates from its own canvas, restoring normal animation speed and stopping offscreen contact animation updates; no actionable merge-blocking risk remains after normal checks and review. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Full details: Docstring CoverageExplanation No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2 files. ✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2c79dacc8f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| useFrame((_, delta) => { | ||
| mixer.update(Math.min(delta, MIXER_MAX_DELTA_SECONDS)); | ||
| }); |
There was a problem hiding this comment.
Add the required PR rationale document
This commit changes mixer ownership and intentionally stops the contact animation while its canvas is inactive, but the reviewed tree has no docs/pr/ directory or markdown record for the change. The repository requires this durable rationale to document design tradeoffs, deliberately unchanged scope, tests, and follow-up blockers; retaining that information only in the commit message does not satisfy the required file-based record. Add the corresponding document under docs/pr/.
AGENTS.md reference: AGENTS.md:L133-L140
Useful? React with 👍 / 👎.
`gl.info.render.frame`은 WebGLRenderer 인스턴스마다 따로 세는 카운터라 캔버스가 둘일 때 서로 비교할 수 없다. 홈에는 hero와 contact 캔버스가 동시에 살아 있고 `useCharacterInstance`가 캔버스마다 호출되므로, 두 useFrame 루프가 각각 "내 프레임 번호는 아까 그 번호와 다르다"고 판단해 매 rAF마다 두 믹서를 모두 갱신했다. 결과적으로 두 믹서가 실시간의 2배로 진행했고, 반대로 두 카운터가 우연히 같은 값이 되는 프레임에는 한쪽 캔버스의 갱신이 통째로 드롭됐다. 가드를 렌더러 식별자+프레임 번호 조합으로 고치는 방법도 있지만, 애초에 공유 가드가 필요한 이유가 없다. 훅은 이미 `instance`를 받고 있고 각 호출은 자기 캔버스의 rAF에서만 실행되므로, 자기 mixer만 진행시키면 "rAF당 한 번, 자기 캔버스의 delta로"가 자연히 성립한다. 그래서 가드와 `lastUpdatedFrame` 캐시 필드를 제거하는 쪽을 골랐다. 부수 효과로 contact 캔버스가 화면 밖이라 frameloop가 멈춘 동안 contactMixer도 함께 멈춘다. 이전에는 hero 루프가 대신 갱신해 주고 있었는데, 보이지도 않는 스켈레톤을 매 프레임 갱신하던 낭비였다. `clampedDelta` 상한은 그대로 둔다. `RenderWhenVisible`이 frameloop을 정지·재개하므로 재개 시점의 delta 점프를 막는 안전장치가 여전히 필요하다.
두 개의 독립적인 렌더러가 각자 프레임을 진행하는 상황을 재현해, 각 믹서가 자기 캔버스의 delta로 rAF당 정확히 한 번만 진행하는지 고정한다. 이 버그는 캔버스 두 개가 동시에 살아 있어야만 드러나므로 단일 캔버스 픽스처로는 잡히지 않는다. 프레임 갱신 판단을 순수 헬퍼로 뽑을 수 있으면 Node 버킷에 두는 게 맞지만, 수정 후에는 판단 로직 자체가 사라지고 "훅 호출마다 자기 mixer만 갱신한다"는 배선만 남는다. 그래서 헬퍼를 억지로 만들지 않고 useFrame 배선을 그대로 검증하는 jsdom 훅 테스트(dom:model 버킷)로 뒀다. 프레임 카운터가 어긋난 경우와 우연히 일치하는 경우를 나눠 검증한다. 전자는 이중 누적, 후자는 갱신 드롭이라는 서로 다른 증상으로 나타나기 때문이다. contact 캔버스가 마운트되지 않은 좁은 뷰포트 시나리오와, frameloop 재개 시 delta 상한(1/30초)도 함께 고정한다.
2c79dac to
3c0ee3d
Compare
F-1~F-4를 다루던 세 브랜치가 모두 머지됐는데(#95·#97·#98) 문서는 그대로여서, 지금 읽으면 이미 끝난 일을 남은 과제로 오해한다. 지시서 3개도 "앞으로 할 일"처럼 읽힌다. README를 "남은 과제 관리" 용도로 다시 잡았다. 끝난 것은 §1에 한 줄과 실측 확인만 남기고 근거는 각 PR로 넘긴다. 문서에 같은 내용을 두 번 적으면 한쪽이 반드시 낡는다. 이번에 다시 확인해 갱신한 것: - GLB 이미지 10장이 전부 `image/ktx2`, 죽은 roughness는 `skin_roughness`만 생존 (F-1·F-2 완료) - 프리페처가 `app/[locale]/(home)/layout.tsx`에 있음 (F-3 완료) - 믹서가 인스턴스별 `mixer.update(min(delta, 1/30))` (F-4 완료) - F-5의 `skin_normal` 항목은 이미 1024²라 해결됨 — 목록에서 뺐다 - F-5 잔여: `hair_color`/`hair_color-hair_alpha` 중복(둘 다 2048² 임베드), `thumbnail.png` 1.77MB PNG 새로 찾은 것 두 개를 추가했다: - **N-1** 웜 캐시 재방문에도 로딩 오버레이가 2.8초. 다운로드 완료 후 꼬리가 ~1.6초로 일정하고, 그 마지막 관문인 `<Preload all />`의 90%가 이 씬이 쓰지도 않는 큐브카메라 6면 렌더다 (`gl.compile` 15~41ms vs `CubeCamera.update` 275~299ms) - **N-2** SkinnedMesh 바운딩 스피어가 bind pose 기준이라 카메라가 가까워지면 눈썹이 컬링된다 VRAM 수치는 이 문서에서 재측정하지 않았으므로 #95를 단일 출처로 명시했다. 공통 규칙에 "성능·타이밍은 headless로 재지 마라"와 아티클 초안 경로도 추가했다. 지시서 3개에는 완료 배너를 달았다. 삭제하지 않은 건 그때의 판단 근거가 남아 있어서다.
`docs/3d-performance/`는 F-1~F-4를 나눠 맡을 세 브랜치를 위해 쓴 작업 문서였다. 그 세 브랜치가 모두 머지되면서(#95·#97·#98) 문서의 본래 역할이 끝났다. 남겨두면 비용만 생긴다. 지시서 3개는 그때의 코드 기준이라 지금 따라 하면 틀리고, README는 상태가 바뀔 때마다 같이 갱신해야 한다. 살아 있어야 할 내용은 PR #103 본문으로 옮겼다. - **아직 안 한 것**(hair 텍스처 중복, thumbnail.png) → 후속 - **파봤지만 코드를 안 바꾼 것**(큐브카메라 기각, 로딩 오버레이 690ms 구성, dev로 재면 결론이 뒤집힌다는 함정) → Changes 곁다리 정리 두 개: - `.gitignore`의 `!docs/3d-performance/` negation이 죽은 설정이 되어 뺐다 - `pr-workflow` 스킬의 예시가 방금 지운 경로를 가리키고 있어 일반화했다 Codex 리뷰 P2(`01-vram-ktx2.md` 번호 매기기 깨짐)는 파일 삭제로 해소된다.
`docs/3d-performance/`는 F-1~F-4를 나눠 맡을 세 브랜치를 위해 쓴 작업 문서였다. 그 세 브랜치가 모두 머지되면서(#95·#97·#98) 역할이 끝났다. 남겨두면 비용만 생긴다. 지시서 3개는 그때의 코드 기준이라 지금 따라 하면 틀리고, README는 상태가 바뀔 때마다 같이 갱신해야 한다. 살아 있어야 할 내용은 PR #103 본문으로 옮겼다. - **아직 안 한 것**(hair 텍스처 중복, thumbnail.png) → 후속 - **파봤지만 코드를 안 바꾼 것**(큐브카메라 기각, 로딩 오버레이 690ms 구성, dev로 재면 결론이 뒤집힌다는 함정) → Changes 곁다리 정리 두 개: - `.gitignore`의 `!docs/3d-performance/` negation이 죽은 설정이 되어 뺐다 - `pr-workflow` 스킬의 예시가 방금 지운 경로를 가리키고 있어 일반화했다 Codex 리뷰 P2(`01-vram-ktx2.md` 번호 매기기 깨짐)는 파일 삭제로 해소된다.
목적
캐릭터 애니메이션이 실시간의 2배 속도로 재생되고 있었다. 홈에 캔버스가 두 개(hero + contact) 마운트되는데, 두 캔버스가 서로의 믹서까지 갱신하고 있었기 때문이다.
mixer.update호출delta적용 범위:
useCharacterInstance하나. 애니메이션 클립·전환 로직·캔버스 설정은 건드리지 않았다.설계
1. 왜
gl.info.render.frame을 캔버스 간 가드로 쓸 수 없는가기존 코드는 "한 프레임에 믹서를 한 번만 갱신"을 이렇게 표현했다.
info.render.frame은WebGLRenderer인스턴스마다 따로 세는 카운터다. 전역 프레임 번호가 아니다. 홈에는 캔버스가 둘이고 각자 자기 렌더러를 가지므로, 두 카운터 사이에는 아무 관계가 없다. contact 캔버스는dynamic({ ssr: false })로 늦게 마운트되니 시작값부터 어긋난다.characterInstanceCache는 모듈 스코프 싱글턴이라 두 캔버스가lastUpdatedFrame한 칸을 공유한다. 그래서 매 rAF마다:lastUpdatedFrame(contact의 번호) ≠ hero 번호lastUpdatedFrame(hero의 번호) ≠ contact 번호가드가 아무것도 막지 못하고
delta만 이중 누적된다. 반대로 두 카운터가 우연히 같은 값이 되는 프레임에는 두 번째 루프가 early return 해서 그 캔버스의 갱신이 통째로 사라진다 — 이쪽이 더 조용한 버그다.2. 왜 가드를 고치지 않고 없앴는가
가드를 살리려면 키를
렌더러 식별자 + 프레임 번호조합으로 바꿔야 한다. 하지만 애초에 공유 가드가 필요한 이유가 없다.훅은 이미
instance: 'main' | 'contact'를 받고, 캔버스마다 한 번씩 호출되며, 각useFrame은 자기 캔버스의 rAF에서만 실행된다. 자기 mixer만 진행시키면 "rAF당 한 번, 자기 캔버스의 delta로"가 구조적으로 성립한다 — 지켜야 할 상태가 없으니 깨질 상태도 없다.같은 인스턴스를 한 페이지에서 두 번 이상 마운트하는 곳이 없음을 확인했다 (
HomeHeroCharacterSeatSet은 hero에서main1회, contact에서contact1회). 가드가 없어지면서CharacterInstanceCache.lastUpdatedFrame필드도 함께 제거했다.3.
clampedDelta(≤ 1/30초)는 그대로 둔다RenderWhenVisible이 오프스크린·백그라운드에서frameloop을'never'로 멈췄다가 재개하므로, 재개 프레임의delta가 정지해 있던 시간만큼 튄다. 상한이 없으면 복귀할 때 애니메이션이 순간이동한다. 이번 변경과 무관한 안전장치라 유지하고, 이름 있는 상수(MIXER_MAX_DELTA_SECONDS)로 올려 의도를 남겼다.4. 부수 효과 — contact가 화면 밖이면 contactMixer도 멈춘다
이전에는 contact 캔버스의 frameloop이 정지해도 hero 루프가 contactMixer를 대신 갱신했다. 이제는 멈춘다. 보이지 않는 스켈레톤을 매 프레임 갱신하던 낭비가 사라지는 것이고, 재개 시 점프는 위의
delta상한이 막는다.Changes
b2a49443c0ee3d건드린 파일은
src/entities/character/model/use-character-instance.ts하나와 새 테스트 파일 하나뿐이다.의도적으로 안 바꾼 것
clampedDelta상한use-render-when-visible.ts,frameloop,<Preload all />character-state-machine,use-character-animation전환 로직characterInstanceCache의 싱글턴 구조리뷰 포인트
info.render.frame이 렌더러별 카운터라는 전제 (설계 1). 이 전제가 틀리면 진단 전체가 무너진다.WebGLRenderer인스턴스마다info가 따로 생성되고, 캔버스가 둘이면 렌더러도 둘이다.instance를 한 페이지에서 두 번 이상 마운트하는 곳이 정말 없는지. 생긴다면 그때는 mixer 단위로렌더러 + 프레임키를 써야 한다.dom:model)로 뒀다. mock은 drei가 아니라useSceneGltf경계에 걸었다. KTX2 도입 - VRAM 666MB → 119MB #95 이후 이 모듈이 import 시점에 draco 경로를 세팅하고 호출 시점에 실제WebGLRenderer에서 KTX2Loader를 만들기 때문에, drei를 흉내 내면 그 내부까지 따라 흉내 내야 한다.Test plan
pnpm run check-typespnpm run lintpnpm run test:vitest— 전 버킷 통과 (신규 4건 포함)pnpm run test:browser— 26 passed위 셋 모두
origin/main(KTX2 도입 - VRAM 666MB → 119MB #95 KTX2 머지) 리베이스 이후 재실행신규 테스트가 수정 전에는 실패하는지 확인 — 4건 중 3건 실패 (
2.5가1로,1.0이1.5로, contactMixer가0이 아닌1.0)실측 재현 — 프로덕션 빌드 + GPU 활성 Chromium, 1600×1100,
/ko에서 스크롤 위치 0~1.0을 11지점 순회하며mixer.time증가율 / 실경과시간 측정프레임 단위로도 확인했다. 같은 rAF에서 각 인스턴스의
useFrame호출 수를 세고 믹서 진행량을 나눈 값:셀프 QA — 프로덕션 빌드 + GPU 활성 Chromium, 콘솔/페이지 에러 0건
visiblefalse↔true 관측사람 눈으로 보는 최종 확인 — 위 계측은 "몇 배로 도는가"를 답하지만 "자연스러운가"는 답하지 않는다
후속
RenderWhenVisible이 contact 캔버스를 다시 멈추지 못한다 — 셀프 QA에서 발견. contact 캔버스가 한 번도 보인 적 없을 때는 정지하지만(useFrame0회/초), 한 번 노출된 뒤 다시 화면 밖으로 나가면 오프스크린인데도 계속 돈다(≈84회/초).origin/main에 같은 계측을 걸어 확인한 결과 이 브랜치 이전부터 있던 동작이다 — 이 PR은frameloop·가시성 로직을 건드리지 않는다. 다만 이번 변경으로 영향이 커졌다. 전에는 hero 루프가 contactMixer를 대신 갱신했으므로 contact 캔버스의 정지 여부가 믹서에 영향을 주지 않았지만, 이제는 그대로 드러난다. 원인이use-render-when-visible.ts(3D 파이프라인 최적화 - gltf, 렌더 루프·화면 텍스처 정비 #93 소유)에 있어 여기서 고치면 이 PR의 진단 범위를 벗어나므로 분리한다.characterInstanceCache싱글턴의 재마운트 동작 —sourceScene동일성으로만 캐시를 판정하므로 라우트 이동 후useGLTF캐시가 비워졌다 다시 채워지는 경로에서 어떻게 동작하는지 별도 확인이 필요하다. 이번 브랜치에서 같이 고치면 프레임 갱신 수정과 캐시 수명 수정이 한 diff에 섞여 되돌리기 어려워지므로 분리한다.perf/3d-vram-ktx2담당. 이 브랜치와 파일이 겹치지 않는다.perf/3d-preload-scope담당.Summary by CodeRabbit