指针到NDC
指针事件给的是 client 坐标。GPU 要的是 NDC / clip。中间还隔着 CSS 盒、DPR、drawing buffer 左下原点。offsetX 在缩放、CSS transform、子像素下不可靠,生产不用。
1. client → CSS 盒 → NDC
export function eventToCss(
canvas: HTMLCanvasElement,
event: PointerEvent,
): { x: number; y: number } {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
}
export function cssToNdc(
cssX: number,
cssY: number,
cssWidth: number,
cssHeight: number,
): { x: number; y: number } {
return {
x: (cssX / cssWidth) * 2 - 1,
y: 1 - (cssY / cssHeight) * 2,
};
}
export function eventToNdc(
canvas: HTMLCanvasElement,
event: PointerEvent,
): { x: number; y: number } {
const css = eventToCss(canvas, event);
const rect = canvas.getBoundingClientRect();
return cssToNdc(css.x, css.y, rect.width, rect.height);
}
NDC 的 Y 已经翻成 WebGL 约定(上为正)。WebGPU 纹理原点不同,但 指针仍按 CSS 盒 换算;差别出在投影矩阵,不要在这一层再翻一次。见 NDC 与 Y 轴。
2. 到世界:乘逆 VP
export function ndcToWorld(
ndcX: number,
ndcY: number,
viewProj: DOMMatrix,
zClip = 0,
): DOMPoint {
const inv = viewProj.inverse();
if (Number.isNaN(inv.a)) {
throw new Error("viewProj is not invertible");
}
return new DOMPoint(ndcX, ndcY, zClip, 1).matrixTransform(inv);
}
正交 2D 相机:zClip = 0 足够。透视相机要对准近平面或做射线,不能假设一个 z 就落在物体上。透视拾取见 GPU 拾取。
viewProj 必须和这一帧 draw* 用的矩阵 同一份。overlay、命中、绘制各算一套,点永远对不齐。
3. 不要用 drawing buffer 像素当 CSS
event.offsetX 有时接近 CSS 像素,有时接近 backing store,取决于浏览器是否把 canvas 当 replaced 元素特殊处理。统一:client - getBoundingClientRect。DPR 只在 上传 viewport / 读像素 时介入,不要在指针路径再乘一次 devicePixelRatio。
4. 失败形态
| 症状 | 原因 |
|---|---|
| 点在上方,高亮在下方 | CSS Y 没翻成就进了 NDC |
| 高分屏点偏一块 | 指针乘了 DPR,矩阵按 CSS 建 |
| 旋转后差一个角 | 命中用了过期 viewProj |
| 透视点不准 | 用正交公式,没做射线 |
权威资料
核对日期:2026-08-26