콘텐츠로 이동

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을 그대로 받으면 다음과 같이 계산된다.

  1. windowRatio = 1176 / 0 = Infinity
  2. coverScreen 확장 단계에서 canvasWidth = canvasHeight * windowRatio = Infinity
  3. 백버퍼 크기 제한 단계에서 resolution *= MAX_BACKBUFFER_SIZE / Infinity가 되어 resolution = 0
  4. 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 = Infinity
point.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인 경우를 걸러 내는 가드를 넣었다.

// 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 선택자나 클래스로 전환하는 방법도 있다.