坐标转换
基本概念
什么是屏幕坐标系?
ThreeJS使用了canvas画布绘制图形,屏幕坐标即画布上的像素位置。注意 vjmap3d 的 project/unproject/screenToNdc/ndcToScreen 用的是浏览器视口 client 坐标(如 event.clientX/clientY,内部会加减容器 getBoundingClientRect() 的 left/top),不是 canvas 局部坐标(offsetX/offsetY)。

什么是世界坐标系?
世界坐标系(三维坐标系)分为两种,左手坐标系和右手坐标系。
左手坐标系:BabylonJS
右手坐标系:ThreeJS(下图就是右手坐标系)

什么是标准化设备坐标系?
一旦你的顶点坐标已经在顶点着色器中处理过,它们就应该是标准化设备坐标(Normalized Device Coordinates,NDC)了,标准化设备坐标是一个x、y和z值在-1.0到1.0的一小段空间。任何落在范围外的坐标都会被丢弃/裁剪,不会显示在你的屏幕上。下面你会看到我们定义的在标准化设备坐标中的三角形(忽略z轴):

透视投影原理

屏幕坐标系、世界坐标系、标准化设备坐标系之间的关系
将二维坐标映射到三维空间时,首先需要将二维坐标转换为 NDC 坐标。这可以通过将二维坐标的值除以视口的尺寸来实现。
一旦获得了 NDC 坐标,可以使用其他投影技术(如透视投影或正交投影)将其转换为三维坐标。
物体坐标系之间的转换关系大致为:
局部坐标 -> 世界坐标 -> 观察空间坐标 -> 裁剪空间坐标 -> 屏幕空间坐标
我们将 观察空间坐标系 和 裁剪空间坐标系 之间的转换统一处理,最终得到 标准设备坐标系
因此坐标转换过程就变成了:
局部坐标 -> 世界坐标 -> 标准设备坐标 -> 屏幕空间坐标
原本世界坐标转换到观察空间坐标需要乘上视图矩阵 CameraMatrixWorldInverse(ViewMatrix)
随后,观察空间坐标转换到裁剪空间坐标需要乘上相机投影矩阵:ProjectMatrix
在 ThreeJS 中有一个方法 Vector3.project(camera) 综合了这两步:
project( camera ) {
return this.applyMatrix4( camera.matrixWorldInverse ).applyMatrix4( camera.projectionMatrix );
}
三维场景坐标转换

// 增加坐标信息
const showCoordInfo = (mesh, isUpdate) => {
let localPosition = mesh.position.clone();
let worldPositon = mesh.localToWorld(localPosition.clone());
let screen = app.project(worldPositon.x, worldPositon.y, worldPositon.z);
let ndc = app.screenToNdc(screen.x, screen.y)
let info = `局部坐标: ${localPosition.x.toFixed(1)},${localPosition.y.toFixed(1)},${localPosition.z.toFixed(1)}<br/>`
info += `世界坐标: ${worldPositon.x.toFixed(1)},${worldPositon.y.toFixed(1)},${worldPositon.z.toFixed(1)}<br/>`
info += `屏幕坐标: ${screen.x.toFixed(1)},${screen.y.toFixed(1)}<br/>`
info += `NDC坐标: ${ndc.x.toFixed(1)},${ndc.y.toFixed(1)}<br/>`
}
let svc = new vjmap3d.Service(env.serviceUrl, env.accessToken);
let app = new vjmap3d.App(svc, {
container: "map", // 容器id
scene: { // 场景设置
gridHelper: { visible: true } // 是否显示坐标网格
}
})
const sun = new THREE.Mesh(
new THREE.SphereGeometry( 0.5, 16, 16 ),
new THREE.MeshBasicMaterial( { color: 0xff0000 } )
)
sun.position.set(-1, 0, -1);
app.scene.add( sun );
const earth = new THREE.Mesh(
new THREE.SphereGeometry( 0.3, 16, 16 ),
new THREE.MeshBasicMaterial( { color: 0x00ff00 } )
)
earth.position.set(4, 0, 0);
sun.add( earth );
const moon = new THREE.Mesh(
new THREE.SphereGeometry( 0.1, 16, 16 ),
new THREE.MeshBasicMaterial( { color: 0x00ffff } )
)
moon.position.set(-1, 0, 0);
earth.add( moon );
//
let angleSunEarth = 0;
let angleEarthMoon = 0;
app.signal.onAppUpdate.add(() => {
angleSunEarth += 0.01; // 控制旋转速度
earth.position.x = 4 * Math.cos(angleSunEarth);
earth.position.z = 4 * Math.sin(angleSunEarth);
// 月亮绕地球转
angleEarthMoon += 0.03; // 月亮旋转速度可以不同于地球以增加真实感
moon.position.x = 1 * Math.cos(angleEarthMoon);
moon.position.z = 1 * Math.sin(angleEarthMoon);
//
showCoordInfo(moon, true)
})
// 增加坐标信息
const showCoordInfo = (mesh, isUpdate, radius, bkColor) => {
let localPosition = mesh.position.clone();
let worldPositon = mesh.localToWorld(localPosition.clone());
let screen = app.project(worldPositon.x, worldPositon.y, worldPositon.z);
let ndc = app.screenToNdc(screen.x, screen.y)
let info = `局部坐标: ${localPosition.x.toFixed(1)},${localPosition.y.toFixed(1)},${localPosition.z.toFixed(1)}<br/>`
info += `世界坐标: ${worldPositon.x.toFixed(1)},${worldPositon.y.toFixed(1)},${worldPositon.z.toFixed(1)}<br/>`
info += `屏幕坐标: ${screen.x.toFixed(1)},${screen.y.toFixed(1)}<br/>`
info += `NDC坐标: ${ndc.x.toFixed(1)},${ndc.y.toFixed(1)}<br/>`
if (isUpdate) {
mesh.popup.setHTML(info);
return;
}
let popup = new vjmap3d.Popup2D({
backgroundColor: bkColor,
closeButton: false,
})
popup.setPosition([0, radius, 0]); // 设置位置
popup.setHTML(info);
popup.setOpacity("0.6")
popup.addTo(mesh, app)
mesh.popup = popup;
}
showCoordInfo(moon, false, 0.1, "#00ffff")
三维互联网地图做为底图场景坐标转换

// 增加坐标信息
const showCoordInfo = (pixel) => {
let screen = pixel;
let worldPositon = app.unproject(screen.x, screen.y);
let ndc = app.screenToNdc(screen.x, screen.y);
let lngLat = mapviewEnt.worldToLngLat(worldPositon);
let localPositon = mapviewEnt.lngLatToLocal(lngLat);
// let localPositon = mapviewEnt.node.worldToLocal(worldPositon.clone()); // 与上面的写法等同
let info = `经纬度: ${lngLat[0].toFixed(5)},${lngLat[1].toFixed(5)}<br/>`
info += `世界坐标: ${worldPositon.x.toFixed(1)},${worldPositon.y.toFixed(1)},${worldPositon.z.toFixed(1)}<br/>`
info += `屏幕坐标: ${screen.x.toFixed(1)},${screen.y.toFixed(1)}<br/>`
info += `NDC坐标: ${ndc.x.toFixed(1)},${ndc.y.toFixed(1)}<br/>`
info += `地图局部坐标: ${localPositon.x.toFixed(6)},${localPositon.y.toFixed(6)},${localPositon.z.toFixed(6)}<br/>`
app.popup2d.setPosition(worldPositon);
app.popup2d.setHTML(info);
}
app.signal.onMouseMove.add(e => showCoordInfo(vjmap3d.eventXY(e)))
let svc = new vjmap3d.Service(env.serviceUrl, env.accessToken);
let app = new vjmap3d.App(svc, {
container: "map", // 容器id
stat: { show: true, left: "0" },
camera: { // 相机设置
viewHelper: { enable: true, position: "leftBottom" } // 是否显示视角指示器
},
control: { leftButtonPan: true } // 设置为左键用于平移 (同时右键将用于旋转) 和地图2d使用习惯一样
})
const tdtVecUrl =
"https://t3.tianditu.gov.cn/DataServer?T=vec_w&X={x}&Y={y}&L={z}&tk=YOUR_TK";
const tdtAnnoUrl = `https://t3.tianditu.gov.cn/DataServer?T=cva_w&X={x}&Y={y}&L={z}&tk=YOUR_TK`;
let provider = new vjmap3d.MapProvider(
[
{
url: tdtVecUrl
},
{
url: tdtAnnoUrl
}
],
{
rootTile: [7, 105, 48]
}
);
let mapviewEnt = new vjmap3d.MapViewEntity({
provider,
baseScale: 100,
});
mapviewEnt.addTo(app);
// 增加坐标信息
const showCoordInfo = (pixel) => {
let screen = pixel;
let worldPositon = app.unproject(screen.x, screen.y);
let ndc = app.screenToNdc(screen.x, screen.y);
let lngLat = mapviewEnt.worldToLngLat(worldPositon);
let localPositon = mapviewEnt.lngLatToLocal(lngLat);
// let localPositon = mapviewEnt.node.worldToLocal(worldPositon.clone()); // 与上面的写法等同
let info = `经纬度: ${lngLat[0].toFixed(5)},${lngLat[1].toFixed(5)}<br/>`
info += `世界坐标: ${worldPositon.x.toFixed(1)},${worldPositon.y.toFixed(1)},${worldPositon.z.toFixed(1)}<br/>`
info += `屏幕坐标: ${screen.x.toFixed(1)},${screen.y.toFixed(1)}<br/>`
info += `NDC坐标: ${ndc.x.toFixed(1)},${ndc.y.toFixed(1)}<br/>`
info += `地图局部坐标: ${localPositon.x.toFixed(6)},${localPositon.y.toFixed(6)},${localPositon.z.toFixed(6)}<br/>`
app.popup2d.setPosition(worldPositon);
app.popup2d.setHTML(info);
}
app.signal.onMouseMove.add(e => showCoordInfo(vjmap3d.eventXY(e)))
三维CAD图做为底图场景坐标转换

提示
使用 fromWorld(世界坐标转CAD坐标) 和 toWorld(CAD坐标转世界坐标) 是为了统一 做为唯杰地图的三维图层坐标转换 CAD坐标转换时的写法。
涉及到距离转换时使用:
fromDist(世界坐标距离 转CAD距离)
toDist(CAD距离 转世界坐标距离)
注意
fromWorld/toWorld/fromDist/toDist 是包级导出函数(vjmap3d.fromWorld(...)),不在 app 实例上;app 上的投影方法是 project/unproject/screenToNdc/ndcToScreen。
fromWorld(vec3) 的入参按数组读取(内部判断 e.length === 3 && e[2] !== undefined 才保留 z):
toWorld()返回THREE.Vector3、app.unproject()也返回Vector3—— 把它们直接传给fromWorld会静默丢弃 z 分量(Vector3.length为 undefined)。文档中fromWorld(unproject结果)的示例只用到cadPos[0]/[1],故表面无碍;若需要 z,务必先position.toArray()(如vjmap3d.fromWorld(position.toArray()),见下文三维图层示例)。- 经纬度↔世界坐标的转换(
lngLatToWorld/worldToLngLat/mapToWorld/worldToMap等)在MapViewEntity实例上,app层没有这些方法。
// 增加坐标信息
const showCoordInfo = (pixel) => {
let screen = pixel;
let worldPositon = app.unproject(screen.x, screen.y);
let ndc = app.screenToNdc(screen.x, screen.y);
let cadPos = vjmap3d.fromWorld(worldPositon);
// let cadPos = mapviewEnt.worldToMap(worldPositon); // 与上面的写法等同
let localPositon = mapviewEnt.mapToLocal(cadPos);
// let localPositon = mapviewEnt.node.worldToLocal(worldPositon.clone()); // 与上面的写法等同
let info = `CAD图: ${cadPos[0].toFixed(2)},${cadPos[1].toFixed(2)}<br/>`
info += `世界坐标: ${worldPositon.x.toFixed(1)},${worldPositon.y.toFixed(1)},${worldPositon.z.toFixed(1)}<br/>`
info += `屏幕坐标: ${screen.x.toFixed(1)},${screen.y.toFixed(1)}<br/>`
info += `NDC坐标: ${ndc.x.toFixed(1)},${ndc.y.toFixed(1)}<br/>`
info += `地图局部坐标: ${localPositon.x.toFixed(6)},${localPositon.y.toFixed(6)},${localPositon.z.toFixed(6)}<br/>`
app.popup2d.setPosition(worldPositon);
app.popup2d.setHTML(info);
}
app.signal.onMouseMove.add(e => showCoordInfo(vjmap3d.eventXY(e)))
let svc = new vjmap3d.Service(env.serviceUrl, env.accessToken);
let app = new vjmap3d.App(svc, {
container: "map", // 容器id
stat: { show: true, left: "0" },
scene: {
gridHelper: { visible: true } // 是否显示坐标网格
},
camera: { // 相机设置
viewHelper: { enable: true, position: "leftBottom" } // 是否显示视角指示器
},
control: { leftButtonPan: true } // 设置为左键用于平移 (同时右键将用于旋转) 和地图2d使用习惯一样
})
let res = await app.svc.openMap({
mapid: env.exampleMapId, // 地图ID,(请确保此ID已存在,可上传新图形新建ID)
mapopenway: vjmap3d.MapOpenWay.GeomRender, // 以几何数据渲染方式打开
style: vjmap3d.openMapDarkStyle()
});
if (res.error) {
showError(res.error);
return;
}
let provider = new vjmap3d.MapProvider(
[
{
url: app.svc.rasterTileUrl()
}
],
{
transparent: true
}
);
let mapBounds = vjmap3d.GeoBounds.fromString(res.bounds)
let scale = 100;
let mapviewEnt = new vjmap3d.MapViewEntity({
provider,
baseScale: scale,
mapBounds: mapBounds.toArray()
});
mapviewEnt.addTo(app);
// 增加坐标信息
const showCoordInfo = (pixel) => {
let screen = pixel;
let worldPositon = app.unproject(screen.x, screen.y);
let ndc = app.screenToNdc(screen.x, screen.y);
let cadPos = vjmap3d.fromWorld(worldPositon);
// let cadPos = mapviewEnt.worldToMap(worldPositon); // 与上面的写法等同
let localPositon = mapviewEnt.mapToLocal(cadPos);
// let localPositon = mapviewEnt.node.worldToLocal(worldPositon.clone()); // 与上面的写法等同
let info = `CAD图: ${cadPos[0].toFixed(2)},${cadPos[1].toFixed(2)}<br/>`
info += `世界坐标: ${worldPositon.x.toFixed(1)},${worldPositon.y.toFixed(1)},${worldPositon.z.toFixed(1)}<br/>`
info += `屏幕坐标: ${screen.x.toFixed(1)},${screen.y.toFixed(1)}<br/>`
info += `NDC坐标: ${ndc.x.toFixed(1)},${ndc.y.toFixed(1)}<br/>`
info += `地图局部坐标: ${localPositon.x.toFixed(6)},${localPositon.y.toFixed(6)},${localPositon.z.toFixed(6)}<br/>`
app.popup2d.setPosition(worldPositon);
app.popup2d.setHTML(info);
}
app.signal.onMouseMove.add(e => showCoordInfo(vjmap3d.eventXY(e)))
做为唯杰地图的三维图层坐标转换

注意
独立 App 中 vjmap3D 为 Y 轴向上;而唯杰地图为 Z 轴向上,因此做为唯杰地图三维图层(MapThreeLayer,app.isMapMode === true)时,vjmap3D 的世界坐标也是 Z 轴向上:vjmap3d.toWorld([x, y, h]) 的高度在 .z,地面为 XY 平面;Y-up 的几何/模型需绕 X 轴转 90°(ResManager.loadModel 在地图模式下会自动转),旋转轴用 (0, 0, 1);FillExtrusionsEntity 的 footprint 坐标直接用 [w.x, w.y](地图模式下 isXYPlane 默认 true)。

const showCoordInfo = (event) => {
let lngLat = event.lngLat;
let cadPos = map.fromLngLat(lngLat);
let worldPos = vjmap3d.toWorld([cadPos.x, cadPos.y]);
let pixel = map.project(lngLat)
let info = `CAD图: ${cadPos.x.toFixed(2)},${cadPos.y.toFixed(2)}<br/>`
info += `经纬度: ${lngLat.lng.toFixed(6)},${lngLat.lat.toFixed(6)}<br/>`
info += `世界坐标: ${worldPos.x.toFixed(2)},${worldPos.y.toFixed(2)},${worldPos.z.toFixed(2)}<br/>`
info += `屏幕坐标: ${pixel.x.toFixed(0)},${pixel.y.toFixed(0)}<br/>`
app.popup2d.setPosition(worldPos);
app.popup2d.setHTML(info);
}
map.on("mousemove", e => showCoordInfo(e))
let svc = new vjmap.Service(env.serviceUrl, env.accessToken)
// 打开地图
let res = await svc.openMap({
mapid: env.exampleMapId, // 地图ID
// @ts-ignore
mapopenway: vjmap.MapOpenWay.GeomRender, // 以几何数据渲染方式打开
style: vjmap.openMapLightStyle() // div为深色背景颜色时,这里也传深色背景样式
})
if (res.error) {
// 如果打开出错
showError(res.error)
return;
}
// 获取地图范围
let mapBounds = vjmap.GeoBounds.fromString(res.bounds);
// 根据地图范围建立几何投影坐标系
let prj = new vjmap.GeoProjection(mapBounds);
//
// 地图对象
let map = new vjmap.Map({
container: 'map', // DIV容器ID
style: svc.rasterStyle(), // 样式,这里是栅格样式
center: prj.toLngLat(mapBounds.center()), // 设置地图中心点
zoom: 2, // 设置地图缩放级别
pitch: 45,
antialias: true, // 反锯齿
renderWorldCopies: false // 不显示多屏地图
});
//
// 关联服务对象和投影对象
map.attach(svc, prj);
await map.onLoad();
//
// 创建3d图层
let mapLayer = new vjmap3d.MapThreeLayer(map, {
stat: {
show: true,
left: "0"
}
});
map.addLayer(new vjmap.ThreeLayer({ context: mapLayer }))
let app = mapLayer.app;
// 在CAD图中心上方创建一个Mesh
let cadLen = mapBounds.width() / 50; // 取cad图长度的1/50
let len = vjmap3d.toDist(cadLen); // cad长度转世界坐标长度
let box = new THREE.BoxGeometry(len, len, len)
const material = new THREE.MeshStandardMaterial({
color: "#f00",
side: THREE.DoubleSide
});
let mesh = new THREE.Mesh(box, material);
let cadPos = [mapBounds.center().x, mapBounds.center().y, cadLen]; // mesh在cad中坐标
//
let worldPos = vjmap3d.toWorld(cadPos);// cad坐标转世界坐标
mesh.position.copy(worldPos);
app.scene.add(mesh);
// 增加坐标信息
const showCoordInfo = (event) => {
let lngLat = event.lngLat;
let cadPos = map.fromLngLat(lngLat);
let worldPos = vjmap3d.toWorld([cadPos.x, cadPos.y]);
let pixel = map.project(lngLat)
let info = `CAD图: ${cadPos.x.toFixed(2)},${cadPos.y.toFixed(2)}<br/>`
info += `经纬度: ${lngLat.lng.toFixed(6)},${lngLat.lat.toFixed(6)}<br/>`
info += `世界坐标: ${worldPos.x.toFixed(2)},${worldPos.y.toFixed(2)},${worldPos.z.toFixed(2)}<br/>`
info += `屏幕坐标: ${pixel.x.toFixed(0)},${pixel.y.toFixed(0)}<br/>`
app.popup2d.setPosition(worldPos);
app.popup2d.setHTML(info);
}
map.on("mousemove", e => showCoordInfo(e))
提示
涉及到距离转换时使用:
fromDist(世界坐标距离 转CAD距离)
toDist(CAD距离 转世界坐标距离)
注意
当三维做为一个图层加入至二维地图中时,为了保持三维相机与二维地图的相机同步,会在场景的根节点加下面一个子节点Group,三维相机与二维地图所有的坐标转换都会作用在这个子节点上。上面所说的世界坐标是指相对于此子节点的世界坐标。而真正的根节点的世界坐标我们用场景坐标来表示。其转换关系为:
scene2World(三维场景中的场景坐标转世界坐标)
world2Scene(三维场景中的世界坐标转场景坐标)
此转换关系极少用到,除非在射线拾取(射线拾取的是场景坐标)或 shader 时会用到。
let svc = new vjmap.Service(env.serviceUrl, env.accessToken)
// 打开地图
let res = await svc.openMap({
mapid: env.exampleMapId, // 地图ID
// @ts-ignore
mapopenway: vjmap.MapOpenWay.GeomRender, // 以几何数据渲染方式打开
style: vjmap.openMapLightStyle() // div为深色背景颜色时,这里也传深色背景样式
})
if (res.error) {
// 如果打开出错
showError(res.error)
return;
}
// 获取地图范围
let mapBounds = vjmap.GeoBounds.fromString(res.bounds);
// 根据地图范围建立几何投影坐标系
let prj = new vjmap.GeoProjection(mapBounds);
//
// 地图对象
let map = new vjmap.Map({
container: 'map', // DIV容器ID
style: svc.rasterStyle(), // 样式,这里是栅格样式
center: prj.toLngLat(mapBounds.center()), // 设置地图中心点
zoom: 2, // 设置地图缩放级别
pitch: 45,
antialias: true, // 反锯齿
renderWorldCopies: false // 不显示多屏地图
});
//
// 关联服务对象和投影对象
map.attach(svc, prj);
await map.onLoad();
//
// 创建3d图层
let mapLayer = new vjmap3d.MapThreeLayer(map, {
stat: {
show: true,
left: "0"
}
});
map.addLayer(new vjmap.ThreeLayer({ context: mapLayer }))
let app = mapLayer.app;
//
let data = [];
let markers = [];
let color1 = new THREE.Color("#0ff");
let color2 = new THREE.Color("#f00");
let cnt = 6
for(let x = 0; x <= cnt; x++) {
for(let y = 1; y <= cnt; y++) {
for(let z = 0; z <= cnt; z++) {
let px = mapBounds.min.x + x * mapBounds.width() / cnt;
let py = mapBounds.min.y + y * mapBounds.height() / cnt;
let pz = z * mapBounds.height() / cnt;
let worldPos = vjmap3d.toWorld([px, py, pz]);
let clr = new THREE.Color().copy(color1).lerp(color2, z / cnt);
let div = vjmap3d.DOM.createStyledDiv(`<div class="markertext${z}"></div>`, `.markertext${z}{font-weight:600;color:#${clr.getHexString()};text-align:center;display:block;padding-top:10px;font-size:1.3em;}`);
let marker2d = new vjmap3d.Marker2D({
element: div,
anchor: 'bottom',
fadeDistance: 300,
allowOverlap: false
});
marker2d.setPosition(worldPos);
marker2d.addTo(app)
markers.push(marker2d);
//
data.push({
position: worldPos.toArray(),
color: clr,
borderColor: clr,
size: 10
})
}
}
}
let symbol = new vjmap3d.SymbolEntity({ //SymbolEntity
data: data,
pixelRaycasting: true, // 像素大小拾取
style: {
shape: "circle",
transparent: true,
//fadeDistance: 500,
borderWidth: 2,
sizeAttenuation: false
}
})
symbol.addTo(app);
const update = () => {
for(let m of markers) {
let position = m.position;
let cad = vjmap3d.fromWorld(position.toArray())
let lnglat = map.toLngLat(cad)
let altitude = vjmap3d.fromDist(position.z, true)
let scene = vjmap3d.map2dUtils.world2Scene(position)
let posArr = [];
posArr.push(`CAD坐标: ${cad[0].toFixed(2)}, ${cad[1].toFixed(2)}, ${cad[2].toFixed(2)}`)
posArr.push(`经纬度坐标: ${lnglat[0].toFixed(2)}, ${lnglat[1].toFixed(2)}, ${altitude.toFixed(2)}`);
posArr.push(`世界坐标: ${position.x.toFixed(2)}, ${position.y.toFixed(2)}, ${position.z.toFixed(2)}`)
posArr.push(`场景坐标: ${scene.x.toFixed(2)}, ${scene.y.toFixed(2)}, ${scene.z.toFixed(2)}`)
m.element.children[0].innerHTML = posArr.join("<br/>")
}
}
update()
setInterval(() => {
update()
}, 3000)