跳到主要内容

指针到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