iframe 풀스크린 해제 후 PixiJS 게임 입력이 막히는 문제 (resizeCanvas resolution 0)
| 증상 | 운영사 페이지에서 “Full Screen”을 눌렀다가 “Exit Full Screen”으로 나오면, 게임 위에 dim이 깔린 것처럼 클릭이 모두 무시된다. 새로고침하기 전까지 복구되지 않는다. |
| 발생 환경 | 노코드 웹 빌더로 만든 특정 운영사 데모 페이지의 페이지 내 Full Screen 버튼. 다른 운영사 배포에서는 발생하지 않았다. |
| 영향 게임 | coverScreen = true로 설정된 게임(게임 A, 게임 B에서 확인). coverScreen = false인 게임 C에서는 발생하지 않는다. |
| 직접 원인 | 풀스크린을 해제하는 순간 iframe 높이가 한 프레임 동안 0으로 보고된다. 이 값으로 Scene.resizeCanvas()를 실행하면 PixiJS EventSystem의 resolution이 0으로 고정되고, 이후 포인터 좌표가 모두 Infinity가 되어 어떤 오브젝트에도 맞지 않는다. |
| 수정 | 프레임워크의 resizeCanvas()가 0 이하의 뷰포트 크기를 무시하고, 유효한 값이 없으면 리사이즈를 건너뛰도록 했다. 게임 A는 프레임워크 서브모듈 포인터를 올려 반영했다. |
원인 흐름
섹션 제목: “원인 흐름”높이 0 프레임 → windowRatio = Infinity → (coverScreen = true일 때만) 캔버스 너비 Infinity → resolution = 0 → EventSystem resolution = 0 → 포인터 좌표 Infinity → 입력 불가로 이어진다. coverScreen = false이면 비율 상한에 걸려 이 경로가 중간에 끊긴다.
- iframe 안의 게임에서 페이지 하단 “Full Screen” 버튼으로 풀스크린에 들어갔다가 “Exit Full Screen” 버튼으로 나온다.
- 화면은 정상적으로 그려지고 애니메이션도 돌아가지만, 버튼을 눌러도 반응이 없다.
- 브라우저 창 크기를 바꾸거나 콘솔에서
resize이벤트를 다시 보내도 풀리지 않는다. - 같은 게임을 다른 운영사 페이지에 넣으면 문제가 없다. 같은 페이지에서도 게임에 따라 재현되기도 하고 되지 않기도 한다.
재현 조건
섹션 제목: “재현 조건”| 페이지 | 게임 | 해제 방법 | 결과 |
|---|---|---|---|
| 운영사 게임 정보 페이지 1 | 게임 B | 페이지의 Exit Full Screen 버튼 | 입력 불가 |
| (같은 페이지 구조) | 게임 A | 페이지의 Exit Full Screen 버튼 | 입력 불가 |
| 운영사 게임 정보 페이지 2 | 게임 C | 페이지의 Exit Full Screen 버튼 | 정상 |
| 운영사 게임 정보 페이지 1 | 게임 B | document.exitFullscreen() 직접 호출 |
정상 |
| 직접 만든 호스트 페이지(iframe / 래퍼 div 풀스크린) | 게임 A | document.exitFullscreen() |
정상 |
테스트는 데스크톱 Chrome에서 진행했다.
조사 과정
섹션 제목: “조사 과정”가설을 순서대로 확인하며 범위를 좁혔다.
| # | 가설 | 확인 방법 | 결과 |
|---|---|---|---|
| 1 | 게임 코드가 풀스크린 이벤트를 처리한다 | 소스에서 fullscreenchange 등을 검색 |
관련 코드가 없고, 리사이즈는 프레임워크의 resizeCanvas()만 처리한다 |
| 2 | 호스트 페이지의 요소가 iframe을 덮고 있다 | top 프레임에서 iframe 중앙의 elementFromPoint와 iframe의 pointer-events 확인 |
최상단 요소가 IFRAME이고 pointer-events: auto여서 배제 |
| 3 | 클릭이 iframe 문서까지 들어오지 않는다 | 게임 iframe에 capture 단계 pointerdown/pointerup 로그 추가 |
pd CANVAS / pu CANVAS가 정상 수신되어 배제 |
| 4 | 게임 루프가 멈췄다 | 1초 동안 rAF 횟수 측정 | 61회/초여서 배제 |
| 5 | 게임 안의 dim이나 팝업이 입력을 막는다 | Pixi rootBoundary.hitTest() |
BtnPlay가 정상적으로 맞아서 배제 |
| 6 | Pixi의 입력 좌표 변환이 깨졌다 | renderer.events.resolution, events.pointer.global 확인 |
resolution = 0, 포인터 좌표 [null, null]로 원인 확인 |
배포 빌드에서는 콘솔로 Pixi 앱 객체에 접근할 수 없다. 그래서 6번은 playwright로 실제 운영사 페이지를 띄우고, 게임 iframe에 init script로 globalThis.__PIXI_APP_INIT__ 훅을 주입해 앱 객체를 잡아서 확인했다.
1. 운영사 페이지: 해제하는 순간 iframe 높이가 한 프레임 동안 0이 된다
섹션 제목: “1. 운영사 페이지: 해제하는 순간 iframe 높이가 한 프레임 동안 0이 된다”페이지는 iframe 크기를 두 요소로 나누어 정한다.
- iframe을 감싼 래퍼 div의 인라인 스타일은
height: 0px이다. iframe은position: absolute; height: 100%로 래퍼를 채우므로, 래퍼의 실제 높이는padding-top으로만 만들어진다(16:9 padding-top 기법). - 화면에 보이지 않는 빌더 HTML 요소 하나가
<style>만 담고 있고, 상태에 따라 내용을 바꾼다.- 평소: 래퍼에
padding-top: 56.25% - 풀스크린: 래퍼에
height: calc(100vh - 50px)
- 평소: 래퍼에
Exit Full Screen 버튼을 눌렀을 때의 실측 타임라인은 다음과 같다.
| 시점 | 일어난 일 | 래퍼 padding-top | iframe 높이 |
|---|---|---|---|
| 풀스크린 중 | height: calc(100vh - 50px) 적용 |
0 | 860 |
| 58ms | fullscreenchange 발생, 레이아웃이 원래 위치로 돌아감 |
0 | 0 |
| 60ms | 빌더가 <style> 노드를 제거한 뒤 다시 추가(padding-top 규칙으로 교체) |
0 | 0 |
| 85ms(다음 프레임) | 새 스타일 적용 | 661.5px | 662 |
풀스크린 규칙이 빠진 뒤 padding-top 규칙이 다시 들어오기 전까지는 래퍼에 인라인 height: 0px만 남는다. 이 사이에 게임 iframe에 resize 1176x0이 한 번 전달된다. document.exitFullscreen()을 직접 호출하면 이 교체 과정을 거치지 않아 높이가 0이 되지 않았다.
2. 프레임워크: 높이 0으로 계산하면 resolution이 0이 된다
섹션 제목: “2. 프레임워크: 높이 0으로 계산하면 resolution이 0이 된다”수정 전 프레임워크의 src/scene/scene.ts에 있는 Scene.resizeCanvas()가 높이 0을 그대로 받으면 다음과 같이 계산된다.
windowRatio = 1176 / 0 = InfinitycoverScreen확장 단계에서canvasWidth = canvasHeight * windowRatio = Infinity- 백버퍼 크기 제한 단계에서
resolution *= MAX_BACKBUFFER_SIZE / Infinity가 되어resolution = 0 renderer.resize(Infinity, h, 0)가 호출된다. 렌더러의 텍스처 소스는resolution || this._resolution으로 처리하므로 0을 무시하고 1을 유지한다. 반면AbstractRenderer.resize()는resolution !== previousResolution이면runners.resolutionChange.emit(0)을 보내고, EventSystem은 이 값을 그대로 받아events.resolution = 0이 된다.
pixi.js EventSystem.mapPositionToPoint (8.14.0)
섹션 제목: “pixi.js EventSystem.mapPositionToPoint (8.14.0)”const resolutionMultiplier = 1 / this.resolution; // 1 / 0 = Infinitypoint.x = (x - rect.left) * (this.domElement.width / rect.width) * resolutionMultiplier;point.y = (y - rect.top) * (this.domElement.height / rect.height) * resolutionMultiplier;그 결과 포인터 좌표가 모두 Infinity가 된다. 클릭 이벤트는 캔버스까지 정상적으로 도착하지만 hit test에서 어떤 오브젝트에도 맞지 않으므로, 화면은 정상인데 입력만 동작하지 않는다.
3. 정상 크기로 돌아와도 복구되지 않는 이유
섹션 제목: “3. 정상 크기로 돌아와도 복구되지 않는 이유”다음 프레임에 resize 1176x662가 들어오면 resolution은 다시 1로 계산된다. 하지만 렌더러의 resolution이 이미 1이므로 resolution !== previousResolution이 거짓이 되어 resolutionChange가 다시 발생하지 않는다. 그래서 EventSystem만 0에 남는다. 콘솔에서 resize 이벤트를 다시 보내도 풀리지 않았던 것도 같은 이유다.
4. 게임 C에서 발생하지 않는 이유
섹션 제목: “4. 게임 C에서 발생하지 않는 이유”게임 C도 같은 페이지 구조라서 해제할 때 resize 1176x0이 똑같이 들어온다. 두 배포 번들의 프레임워크 resizeCanvas 코드와 MAX_BACKBUFFER_SIZE(8192)도 같았다. 차이는 각 게임 main의 렌더링 설정에 있다.
| 설정 | 게임 C | 게임 A / 게임 B |
|---|---|---|
coverScreen |
false(기본값) | true |
enableFlexibleRatio |
true(기본값) | false |
maxRatio |
4:1 | (영향 없음) |
coverScreen이 꺼져 있으면 비율 계산이 min(Infinity, maxRatio)로 상한에 걸려 캔버스 너비가 4320에서 멈춘다(실측 screen=4320x1080). 백버퍼가 8192를 넘지 않으므로 resolution도 1로 유지된다. coverScreen을 켠 게임은 실제 화면 비율을 상한 없이 따라가기 때문에 Infinity가 resolution까지 전달된다.
프레임워크에 iframe 높이가 0인 경우를 걸러 내는 가드를 넣었다.
src/scene/scene.ts: Scene.resizeCanvas()
섹션 제목: “src/scene/scene.ts: Scene.resizeCanvas()”// 0은 값이 없는 것으로 본다. iframe 높이가 0인 순간 visualViewport가 0이면// 비율이 Infinity가 되어 캔버스 CSS 높이가 0으로 고정된다.const positive = (value: number | undefined) => (value !== undefined && value > 0 ? value : undefined);const windowWidth = positive(window.visualViewport?.width) ?? positive(document.documentElement.clientWidth) ?? positive(window.innerWidth);const windowHeight = positive(window.visualViewport?.height) ?? positive(document.documentElement.clientHeight) ?? positive(window.innerHeight);if (windowWidth === undefined || windowHeight === undefined) return;const windowRatio = windowWidth / windowHeight;- 후보 값 중 0 이하는 버리고 다음 후보로 넘어가며, 남는 값이 없으면 리사이즈를 건너뛴다.
return이m_lastWindowSize갱신과onWindowSizeChange호출보다 앞에 있으므로 0 크기가 게임 쪽 콜백이나 렌더러로 전달되지 않는다.- 높이가 0인 iframe에서는 세 후보가 모두 0이라서 리사이즈를 건너뛰고, 바로 다음의 정상 크기 resize에서 원래대로 계산된다.
게임 A는 프레임워크 서브모듈 포인터를 수정 버전으로 올렸다. npx tsc --noEmit은 서브모듈을 포함해 에러 0건이다.
playwright로 실제 운영사 페이지(페이지 1)를 띄우고, iframe의 src를 로컬 dev 서버의 게임 A(수정 후 빌드)로 바꿔서 확인했다.
| 시나리오 | 빌드 | 결과 |
|---|---|---|
| Full Screen → Exit Full Screen 후 BET | 게임 A 수정 후 빌드 | BET 동작(잔액 10,000 → 9,998). 단, 이 실행에서는 해제 시 크기가 1176x850으로 들어와 높이 0 프레임이 발생하지 않았다 |
래퍼의 padding-top을 강제로 0으로 만들기 3회(resize 1176x0 실제 발생) |
게임 A 수정 후 빌드 | events.resolution 1 유지, 이후 BET 동작 |
| 같은 강제 0 시나리오(대조군) | 게임 B 배포 빌드(수정 전) | events.resolution = 0 재현 |
임시 우회(수정 전 빌드에서 이미 멈춘 경우)
섹션 제목: “임시 우회(수정 전 빌드에서 이미 멈춘 경우)”운영사 페이지의 top 프레임 콘솔에서 실행한다. 게임 iframe 컨텍스트로 바꿀 필요는 없다.
(async () => { const f = document.querySelector('iframe'); const w = f.style.width, h = f.style.height; const wait = () => new Promise(r => setTimeout(r, 300)); f.style.setProperty('width', '6000px', 'important'); f.style.setProperty('height', '50px', 'important'); f.style.setProperty('max-width', 'none', 'important'); await wait(); ['width', 'height', 'max-width'].forEach(p => f.style.removeProperty(p)); f.style.width = w; f.style.height = h;})();iframe을 잠깐 너비 6000px, 높이 50px로 바꿔 렌더러의 resolution을 1 아래로 떨어뜨렸다가 되돌린다. 이때 resolutionChange가 다시 발생해 EventSystem도 1로 갱신된다. 실행하는 동안 화면이 잠깐 깨져 보이는 것은 정상이다. 풀스크린을 다시 해제하면 또 멈추므로 그때마다 다시 실행해야 한다.
운영사 페이지 쪽 개선 제안
섹션 제목: “운영사 페이지 쪽 개선 제안”게임 쪽 문제는 프레임워크 수정으로 해결됐다. 다만 운영사 페이지가 해제 중에 iframe 높이를 0으로 만드는 동작은 그대로 남아 있으므로, 필요하면 아래 내용을 참고로 전달한다.
- 래퍼의 인라인
height: 0px대신aspect-ratio: 16 / 9를 상시 규칙으로 두고 풀스크린일 때만height로 덮어쓰면, 스타일을 교체하는 중에도 높이가 0으로 떨어지지 않는다. - 또는
<style>내용을 바꾸지 말고 두 규칙을 상시로 둔 채:fullscreen선택자나 클래스로 전환하는 방법도 있다.