# 截圖驗收測出來的「對」，可能是假的

- URL: https://justfly.idv.tw/%e6%88%aa%e5%9c%96%e9%a9%97%e6%94%b6%e6%b8%ac%e5%87%ba%e4%be%86%e7%9a%84%e3%80%8c%e5%b0%8d%e3%80%8d%ef%bc%8c%e5%8f%af%e8%83%bd%e6%98%af%e5%81%87%e7%9a%84/
- 日期: 2026-09-21
- 分類: 我知故我在
- 標籤: debug, QA, 自動化測試, 軟體工程

![截圖驗收測出來的「對」，可能是假的]

自動截圖流程跑了一段時間，某個新做的全螢幕覆蓋介面，拍出來的畫面裡完全不存在。另一批光圈、面板邊界，整組偏掉。程式碼檢查不出問題，真機上跑起來也一切正常。唯一不對勁的，是那張截圖。

這感覺像拿一面比例被拉長的鏡子照鏡子，對著鏡子把衣服改到「看起來對」，結果走到街上才發現，原本那件才合身。麻煩的地方在於，鏡子不會提醒是哪種鏡子。

##### 分界點在哪裡

截圖流程把畫面渲染到一張離屏畫布，用的是目標機型的高解析度。但程式在執行期查詢到的「螢幕寬高」，回報的卻是承載這張畫布的小尺寸視窗。兩個數字從頭到尾沒有一致過。

凡是用[世界座標](https://zh.wikipedia.org/wiki/%E4%B8%96%E7%95%8C%E5%9D%90%E6%A8%99%E7%B3%BB)定位、直接畫在場景層的元素，截圖是對的。凡是走「把世界座標換算成螢幕座標再擺」這條路，或是直接讀螢幕高度去算比例的，截圖一律錯。還有一類更乾脆——覆蓋層根本不歸任何攝影機渲染，離屏擷取天生就拍不到它，跟座標系無關，它壓根不在畫面裡。

##### 容易誤判的地方

最貴的不是偏移本身，是它看起來像一個普通的版面 bug。順著這個假象去改程式、改偏移量，把真機上本來正確的東西改壞，下一輪截圖「終於對了」——對的是截圖，錯的是真機。

更麻煩的是這條路徑永遠不會報錯。驗證管線持續給綠燈，或者給一個看起來合理的紅燈，而它量的根本是另一個空間。功能壞掉，至少會有錯誤訊息。驗證工具自己在說謊，沒有任何訊號會提示要去懷疑驗證工具本身。

##### 怎麼確認

不從版面下手。先讓程式在截圖環境裡，把自己查到的環境參數印在畫面上，跟輸出檔案的實際尺寸對一下。兩個數字不一樣，問題就結案了，不用再往版面邏輯裡找。

接著把每個介面元素分三類：純世界座標、依賴執行期環境查詢、不歸攝影機渲染。第一類，才是這套截圖能驗的範圍。剩下兩類，截圖給的答案，不管綠燈紅燈，都不能信。

##### 留給下一次

任何自動化視覺驗收，第一件事該是確認它的座標系跟真機是同一個，這比多寫幾個測試案例重要。介面設計上，可見層盡量用不依賴執行期查詢的座標定位，互動熱區留成透明——可見層截圖能驗，互動層本來就該上真機。

如果一套驗證管線對某類問題結構性地沒有偵測能力，這件事要寫進筆記裡，白紙黑字。不然半年後換一個人接手，會再花一整天，去追同一個幽靈。若為了繞過限制在截圖路徑上改了渲染方式，記得寫下因此放棄驗證了什麼——比如層級遮蔽關係——不然那一格會被預設成已經驗過。

##### 技術環境

遊戲引擎（Unity 或 Unreal）為底層的 UI 系統。截圖管線以 `RenderTexture` 為目標機型解析度（例 1920×1080）建立離屏畫布，啟動離屏相機渲染。執行期透過 `Screen.width` / `Screen.height` API 查詢螢幕尺寸——回報的是承載這張畫布的小尺寸視窗（例 Editor 視窗 800×600），不是離屏畫布解析度。UI 元素分兩條路徑：世界座標直接畫入場景層，或依賴執行期螢幕查詢換算；覆蓋層（overlay）則可能用 Screen Space – Camera 或 World Space 不同 canvas mode，後者完全不歸任何攝影機渲染。

##### 錯誤傳染鏈（時序）

渲染管線建立 → 離屏畫布（targetResolution）→ 執行期 `Screen.width` 查詢（小視窗尺寸）→ 世界座標元素直接繪製 → 截圖輸出正確

　　　　　　　　　　　　　　　　　　　　　　　　　　　　　 ↘ 螢幕座標換算路徑元素（讀 `Screen.height` 算比例）→ 截圖偏移 ✗

　　　　　　　　　　　　　　　　　　　　　　　　　　　　　 ↘ 不歸任何相機的 overlay 層 → 截圖完全不可見 ✗✗

##### Code 對照：修法前後

修法前：離屏畫布用目標機型解析度，但 UI 元素的比例計算讀執行期的 `Screen.height`——拿到的是 Editor 小視窗高度而非離屏畫布高度，導致 UI 在截圖裡整體縮小。

```
// screenshot_pipeline.cs — 截圖環境設定
int canvasW = targetDevice.width;   // 1920
int canvasH = targetDevice.height;  // 1080
RenderTexture rt = new RenderTexture(canvasW, canvasH, 24);
// ↓ 離屏渲染時 query 螢幕高度（拿到 Editor 視窗尺寸）
int screenH = Screen.height;        // 600 ← 錯誤源
float ratio = screenH / canvasH;    // 0.55 ← UI 在截圖中縮小為 55%
```

修法後：截圖環境裡不再依賴 `Screen.width` / `Screen.height` 計算比例，預先以 `targetDevice` 解析度為基準；或在程式內把這兩個 API 的值印在畫面上，與輸出 PNG 的實際尺寸比對，差值就是問題點。

```
// screenshot_pipeline.cs — 截圖環境設定
int canvasW = targetDevice.width;   // 1920
int canvasH = targetDevice.height;  // 1080
RenderTexture rt = new RenderTexture(canvasW, canvasH, 24);
// ↓ 比例直接以 targetDevice 為基準，不再讀執行期 Screen
float ratio = 1.0f;                 // 截圖環境與目標機型一致
// 或：在畫面角落印出 Screen.width/height，與 PNG 實際尺寸對照
Debug.Log($"Screen={Screen.width}x{Screen.height}, output={canvasW}x{canvasH}");
```

##### 該被隔離的側效應類型

自動 CI 截圖驗收：管線持續綠燈，但實際 UI 在真機已壞——驗證工具量的根本是另一個空間。

為修截圖而改的渲染參數：把真機上原本正確的東西改壞，下一輪截圖「終於對了」，對的是截圖，錯的是真機。

全螢幕覆蓋層（overlay）：若不歸任何相機渲染，截圖永遠拍不到，驗證工具結構性失明。

層級遮蔽關係（layer occlusion）：為了讓截圖看到某層而調整渲染順序，會破壞真機的 z-order。

光圈與面板邊界：依賴螢幕高度的比例計算，在小視窗截圖環境與目標機型之間會整批偏掉。

設計師驗收流程：縮圖上看 UI 通過 → 開發者接手實機驗收才發現偏移，中間沒人發現空間不一致。

螢幕方向切換：直橫轉換時 `Screen.width/height` 互換，錯誤比例放大。

歷史回歸測試：截圖 baseline 與新版本比對時，若 baseline 本身是用錯誤比例拍的，偏移會被視為「正常」。

##### 三類 UI 元素的截圖適用性

純世界座標、直接畫入場景層的元素——這套截圖能驗，答案可信。

依賴執行期環境查詢（讀 `Screen.width` / `Screen.height` 換算比例）的元素——截圖給的綠燈或紅燈都不能信。

不歸任何相機渲染的 overlay 層——截圖永遠拍不到，必須上真機驗，別為它改渲染路徑。

— 邱柏宇

### When Your Screenshot Test Passes for the Wrong Reason

An automated screenshot pipeline had been running for a while when a newly built full-screen overlay simply didn’t show up in the output. A separate batch of aperture and panel-boundary elements came out shifted, as a group. The code checked out clean. On a real device everything rendered fine. Only the screenshot was wrong.

It’s the same trap as checking an outfit in a mirror that stretches proportions — adjusting the clothes until they look right in the mirror, then stepping outside and realizing the original fit was correct all along. The mirror never tells what kind of mirror it is.

##### Where the split happens

The pipeline renders the frame onto an offscreen canvas sized to the target device’s high resolution. But the runtime API queried for “screen size” reports the dimensions of the small window hosting that canvas. The two numbers never match.

Anything positioned in [world coordinates](https://en.wikipedia.org/wiki/World_coordinate_system) and drawn directly in the scene layer comes out correct in the screenshot. Anything that converts world coordinates into screen coordinates before placement, or reads screen height to compute a ratio, comes out wrong every time. A third category doesn’t belong to either bucket: overlays that aren’t rendered by any camera at all. The offscreen capture simply can’t see them — that has nothing to do with coordinate systems, they’re not in the frame to begin with.

##### Why it’s easy to misdiagnose

The expensive part isn’t the offset. It’s that the offset looks exactly like an ordinary layout bug. Chasing that assumption leads to editing code, nudging offsets, breaking something that was already correct on the real device — and the next screenshot finally “passes.” What passed was the screenshot. What broke was the device.

Worse, this path never throws an error. The verification pipeline keeps returning green, or a red that looks plausible, while measuring an entirely different space. A broken feature at least produces an error message. A lying verification tool produces none — nothing flags that the tool itself deserves suspicion.

##### How to confirm it

Don’t start with the layout. Have the code print its own queried environment parameters directly onto the frame inside the screenshot environment, then compare that against the actual output file dimensions. If the two numbers differ, the case is closed — no need to keep digging through layout logic.

Then sort every UI element into three buckets: pure world coordinates, dependent on runtime environment queries, or not camera-rendered at all. Only the first bucket is something this screenshot pipeline can actually validate. For the other two, green or red, the result can’t be trusted.

##### Notes for next time

The first thing to check in any automated visual verification is whether its coordinate system matches the real device — that matters more than writing a few extra test cases. On the design side, visible layers work best positioned without runtime environment queries where possible, with interactive hit zones left transparent: visible layers are what screenshots can verify, interactive layers were always going to need a real device.

If a verification pipeline is structurally blind to a category of bug, that gets written down explicitly. Otherwise someone else spends another full day chasing the same ghost six months later. And if the rendering path gets modified just to work around this limitation, noting exactly what got sacrificed in the process — layering and occlusion, for instance — keeps that box from quietly getting marked as verified when it never was.

##### Technical Environment

Game engine (Unity or Unreal) with a UI system built on top. The screenshot pipeline creates an offscreen `RenderTexture` at the target device’s high resolution (e.g., 1920×1080), then activates an offscreen camera to render into it. At runtime, the `Screen.width` / `Screen.height` APIs return the dimensions of the small window hosting the canvas (e.g., an Editor window at 800×600) — not the offscreen canvas resolution. UI elements travel two paths: world-coordinate elements drawn directly into the scene layer, or elements that depend on runtime screen queries for conversion. Overlay layers may use Screen Space – Camera or World Space canvas modes; the latter are not rendered by any camera at all.

##### Error Propagation Sequence

Pipeline setup → Offscreen canvas (targetResolution) → Runtime `Screen.width` query (small window size) → World-coordinate elements drawn directly → Screenshot output ✓

　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　 ↘ Screen-coordinate conversion path (reads `Screen.height` for ratio) → Screenshot shifted ✗

　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　 ↘ Camera-unrendered overlay layer → Screenshot invisible ✗✗

##### Code Diff: Before and After

Before: the offscreen canvas uses the target device resolution, but UI elements read `Screen.height` at runtime for ratio calculation — that returns the Editor window height, not the canvas height, so UI scales down to roughly 55% in screenshots.

```
// screenshot_pipeline.cs — screenshot environment setup
int canvasW = targetDevice.width;   // 1920
int canvasH = targetDevice.height;  // 1080
RenderTexture rt = new RenderTexture(canvasW, canvasH, 24);
// ↓ during offscreen render, query screen height (returns Editor window size)
int screenH = Screen.height;        // 600 ← root cause
float ratio = screenH / canvasH;    // 0.55 ← UI rendered at 55% in screenshot
```

After: the screenshot environment no longer reads `Screen.width` / `Screen.height` for ratio calculation. The target device resolution becomes the baseline, and a debug print overlays the queried screen dimensions onto the frame for direct comparison against the PNG output size.

```
// screenshot_pipeline.cs — screenshot environment setup
int canvasW = targetDevice.width;   // 1920
int canvasH = targetDevice.height;  // 1080
RenderTexture rt = new RenderTexture(canvasW, canvasH, 24);
// ↓ ratio comes from targetDevice, not from runtime Screen
float ratio = 1.0f;                 // screenshot environment matches target device
// or: print Screen.width/height on the frame for direct PNG comparison
Debug.Log($"Screen={Screen.width}x{Screen.height}, output={canvasW}x{canvasH}");
```

##### Side Effects That Should Be Isolated

Automated CI screenshot validation: the pipeline keeps returning green while real-device UI is broken — the tool is measuring an entirely different space.

Rendering tweaks made to fix screenshots: real-device output gets broken in the process; the next screenshot “passes” but only because the screenshot space was adjusted.

Full-screen overlays: any layer not rendered by a camera is structurally invisible to offscreen capture; the tool is blind to that category.

Layer occlusion: adjusting render order to make a layer visible in screenshots destroys the real-device z-order.

Aperture and panel-boundary elements: anything that depends on screen-height ratios shifts as a batch between small-window screenshot environments and target devices.

Designer review flow: designers sign off on thumbnails where UI looks correct; developers catch the offset only on real-device validation, with no signal in between.

Screen orientation changes: portrait/landscape swaps invert `Screen.width` / `Screen.height`, amplifying the ratio error.

Historical regression baselines: if earlier baselines were captured with the wrong ratio, subsequent diffs normalize against the offset and stop flagging it.

##### Three Buckets of UI Elements and What Screenshots Can Verify

Pure world-coordinate elements drawn directly into the scene layer — this pipeline can verify them; the answer is trustworthy.

Elements that depend on runtime environment queries (read `Screen.width` / `Screen.height` for ratio) — green or red, neither is reliable.

Overlay layers not rendered by any camera — screenshots cannot see them at all; real-device validation is required, and the rendering path should not be modified just to bring them into frame.

— 邱柏宇

— 邱柏宇
