Skip to content

Commit 4fac22b

Browse files
authored
Merge pull request #104 from Geodan/fix_roll_pitch_yaw
fix roll pitch yaw
2 parents 4167dcd + b878236 commit 4fac22b

7 files changed

Lines changed: 413 additions & 32 deletions

File tree

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ Input database table contains following columns:
5858

5959
. scale - double with instance scale (all directions);
6060

61-
. rotation - double with horizontal rotation angle (0 - 360 degrees);
61+
. rotation - double with horizontal rotation angle (0 - 360 degrees) (legacy, used in i3dm / non-GPU mode);
62+
. yaw - double with yaw/heading in degrees (clockwise-positive);
63+
. pitch - double with pitch in degrees;
64+
. roll - double with roll in degrees;
6265

6366
. tags - json with instance attribute information;
6467

@@ -150,6 +153,12 @@ $ i3dm.export -c "Host=localhost;Username=postgres;Password=postgres;Database=t
150153

151154
For getting started with i3dm.export tool see [getting started](docs/getting_started.md).
152155

156+
## Technical documentation (transforms)
157+
158+
For a detailed explanation of how instance transforms are computed (ECEF/ENU to glTF Y-up, handedness, matrix conventions, and GPU instancing TRS composition), see:
159+
160+
- [Technical notes: coordinate systems & transforms](docs/technical-transforms.md)
161+
153162
## Benchmarking
154163

155164
1] Benchmark on Daily Digital Obstacles files

docs/technical-transforms.md

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# Technical notes: coordinate systems & transforms
2+
3+
This document explains how `i3dm.export` computes **translation**, **rotation**, and **scale** for instances in both output modes:
4+
5+
- `--use_gpu_instancing=true`: outputs a `.glb` that uses `EXT_mesh_gpu_instancing`.
6+
- `--use_gpu_instancing=false`: outputs `i3dm` (or `cmpt` containing `i3dm`) using `NORMAL_UP` / `NORMAL_RIGHT`.
7+
8+
It also documents the coordinate-system conversions between **ECEF (EPSG:4978)** and **glTF Y-up**, and clarifies the **matrix conventions** used by glTF vs `System.Numerics`.
9+
10+
## 1) Coordinate systems
11+
12+
### 1.1 ECEF (EPSG:4978)
13+
Internally, instance positions are converted to **ECEF** (Earth-Centered, Earth-Fixed) coordinates.
14+
15+
- Right-handed coordinate system.
16+
- Units: meters.
17+
- Axes (typical convention):
18+
- +X: intersection of equator and prime meridian.
19+
- +Y: 90° east on equator.
20+
- +Z: north pole.
21+
22+
### 1.2 Local tangent frame (ENU)
23+
For each instance position we derive a local tangent basis:
24+
25+
- **E**: East (tangent)
26+
- **N**: North (tangent)
27+
- **U**: Up (surface normal / ellipsoid normal)
28+
29+
This ENU basis is right-handed:
30+
31+
```
32+
E × N = U
33+
```
34+
35+
Implementation note:
36+
- `SpatialConverter.EcefToEnu(position)` computes an orthonormal E/N/U basis at that ECEF position.
37+
- `EnuCalculator.GetLocalEnuCesium(position, heading, pitch, roll)` starts from that base frame and applies yaw/pitch/roll.
38+
39+
### 1.3 glTF space (Y-up)
40+
glTF uses a **right-handed** coordinate system with:
41+
42+
- +X right
43+
- +Y up
44+
- +Z forward
45+
46+
The exporter outputs **Y-up** glTF.
47+
48+
### 1.4 ECEF → glTF Y-up swizzle
49+
The exporter maps vectors/points from ECEF to glTF Y-up using the same swizzle in both position and orientation code:
50+
51+
```
52+
ToYUp(x, y, z) = ( x, z, -y )
53+
```
54+
55+
So:
56+
- ECEF +Z (up-ish) becomes glTF +Y.
57+
- ECEF +Y becomes glTF -Z.
58+
59+
This keeps the resulting glTF basis right-handed.
60+
61+
## 2) Angle conventions (Yaw / Pitch / Roll)
62+
63+
Angles are in **degrees**.
64+
65+
For GPU instancing, the instance record provides:
66+
67+
- **Yaw**: rotation around local **Up** axis (heading)
68+
- **Pitch**: rotation around local **East** axis
69+
- **Roll**: rotation around local **North/Forward** axis
70+
71+
Important: the code uses the same convention as the legacy non-GPU rotation: **clockwise-positive** (as seen from the positive axis direction).
72+
73+
Implementation note:
74+
- `Rotator.RotateVector(...)` converts clockwise-positive degrees into the standard right-hand-rule rotation by using `360 - angle` internally.
75+
76+
## 3) Matrix conventions: glTF vs System.Numerics
77+
78+
### 3.1 glTF convention
79+
- Matrices are stored **column-major** in the file.
80+
- Transforms conceptually use **column vectors**:
81+
82+
```
83+
world = M * local
84+
```
85+
86+
### 3.2 System.Numerics convention
87+
`System.Numerics.Matrix4x4` + `Vector3.Transform(v, M)` uses **row-vector semantics**:
88+
89+
```
90+
world = local * M
91+
```
92+
93+
This is the single biggest source of confusion when converting between math written for glTF/Cesium (column vectors) and code using `System.Numerics`.
94+
95+
### 3.3 How this repo constructs rotation matrices
96+
When we build a rotation matrix from a basis, we intentionally store the **world basis vectors in the matrix rows** so that:
97+
98+
- local X maps to East
99+
- local Y maps to Up
100+
- local Z maps to Forward
101+
102+
With row-vector semantics that means:
103+
104+
```
105+
(1,0,0) * M = East
106+
(0,1,0) * M = Up
107+
(0,0,1) * M = Forward
108+
```
109+
110+
This is why `GPUTileHandler.GetTransformationMatrix(...)` writes basis vectors into the **rows**.
111+
112+
## 4) Export mode: `--use_gpu_instancing=true` (EXT_mesh_gpu_instancing)
113+
114+
### 4.1 What glTF applies at runtime
115+
In glTF, each mesh node has a node transform (TRS or matrix). With `EXT_mesh_gpu_instancing`, each instance adds its own TRS.
116+
117+
Conceptually (glTF / column vector notation):
118+
119+
```
120+
worldVertex = NodeWorld * InstanceTRS * vertex
121+
```
122+
123+
(Where `NodeWorld` includes the full scene graph above the mesh node.)
124+
125+
### 4.2 Preserving node transforms from the input model
126+
Many models (including Blender exports) include **axis-correction** or other transforms in the scene graph.
127+
If we drop them, some nodes will be misplaced or rotated.
128+
129+
This exporter preserves per-node transforms by collecting all nodes with meshes and using their `node.WorldMatrix`.
130+
131+
Code path:
132+
- `GPUTileHandler.CollectNodesWithMeshes(...)`
133+
- Each mesh node contributes:
134+
- the mesh geometry
135+
- the node’s `WorldMatrix`
136+
137+
### 4.3 Instance TRS calculation
138+
For each instance we compute:
139+
140+
1) **Position** (ECEF) → **glTF Y-up** using `ToYUp(Point)`.
141+
142+
2) **Orientation**:
143+
- Compute ENU basis at the ECEF position.
144+
- Apply yaw/pitch/roll in ENU.
145+
- Swizzle each basis vector to glTF Y-up: `ToYUp(Vector3)`.
146+
- Re-orthonormalize to reduce numerical drift.
147+
- Build a rotation matrix whose rows are `{East, Up, Forward}`.
148+
- Convert to quaternion with `Quaternion.CreateFromRotationMatrix`.
149+
150+
3) **Scale**:
151+
- Uniform: `Scale`
152+
- Non-uniform: `ScaleNonUniform[3]` when `--use_scale_non_uniform=true`.
153+
154+
### 4.4 Combining node and instance transforms
155+
Each output mesh node uses:
156+
157+
- `nodeTransform = node.WorldMatrix` (from the input model)
158+
- `instanceTransform = TRS` computed above
159+
160+
And we create a combined transform:
161+
162+
- `combined = nodeTransform * instanceTransform`
163+
164+
(Exact multiplication order is handled by `AffineTransform.Multiply(...)` in SharpGLTF; the intended effect is “apply node’s authored transform, then apply instance TRS”.)
165+
166+
### 4.5 RTC (relative-to-center) translation
167+
To keep numbers small, we use the first instance position as a per-tile translation anchor.
168+
169+
- We subtract this anchor from each instance translation.
170+
- At the end, the anchor is applied back to nodes.
171+
172+
This improves numerical precision in clients.
173+
174+
## 5) Export mode: `--use_gpu_instancing=false` (i3dm)
175+
176+
In i3dm, per-instance orientation is encoded via two vectors:
177+
178+
- `NORMAL_RIGHT` (local +X)
179+
- `NORMAL_UP` (local +Y)
180+
181+
The client derives the final orientation from these vectors.
182+
183+
Current behavior:
184+
- The non-GPU path currently uses the legacy single **horizontal rotation** value (`rotation`) as a heading angle.
185+
- It derives `NORMAL_RIGHT` and `NORMAL_UP` from the ENU basis.
186+
187+
(If/when full yaw/pitch/roll is enabled for i3dm, the same ENU + swizzle principles apply; only the storage format differs.)
188+
189+
## 6) Common pitfalls / why models can look “tilted”
190+
191+
1) **Mixing handedness or sign conventions**
192+
- Clockwise-positive vs right-hand-rule is easy to flip.
193+
194+
2) **Row-vectors vs column-vectors**
195+
- A basis written into columns will be wrong when used with `Vector3.Transform(v, M)`.
196+
197+
3) **Dropping input node transforms**
198+
- Some models rely on an axis-correction node; if ignored, the model will be sideways or parts won’t move.
199+
200+
4) **Non-orthonormal basis drift**
201+
- Small floating point errors can accumulate; we re-orthonormalize the basis before creating quaternions.
202+
203+
## 7) Code pointers
204+
205+
- ENU basis: `src\Cesium\SpatialConverter.cs` (`EcefToEnu`)
206+
- Y-up swizzle: `src\GPUTileHandler.cs` (`ToYUp`)
207+
- Instance TRS (GPU): `src\GPUTileHandler.cs` (`GetInstanceTransform`)
208+
- Node transform preservation: `src\GPUTileHandler.cs` (`CollectNodesWithMeshes`)
209+
- Yaw/pitch/roll application: `src\EnuCalculator.cs`

i3dm.export.sln

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11

22
Microsoft Visual Studio Solution File, Format Version 12.00
3-
# Visual Studio Version 17
4-
VisualStudioVersion = 17.4.33403.182
3+
# Visual Studio Version 18
4+
VisualStudioVersion = 18.4.11506.43 insiders
55
MinimumVisualStudioVersion = 10.0.40219.1
66
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "doc", "doc", "{68333F7F-D9B8-4215-AE46-6B3AFF14FFBB}"
77
ProjectSection(SolutionItems) = preProject
88
docs\Box.glb = docs\Box.glb
99
docs\getting_started.md = docs\getting_started.md
1010
README.md = README.md
1111
docs\screenshot.png = docs\screenshot.png
12+
docs\technical-transforms.md = docs\technical-transforms.md
1213
docs\trees.png = docs\trees.png
1314
EndProjectSection
1415
EndProject

src/EnuCalculator.cs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,40 @@ public class EnuCalculator
77
{
88
public static (Vector3 East, Vector3 North, Vector3 Up) GetLocalEnu(double angle, Vector3 position)
99
{
10-
return GetLocalEnuCesium(position, (float)angle, 0, 0);
10+
return GetLocalEnuCesium(position, angle, 0, 0);
1111
}
1212

1313
public static (Vector3 East, Vector3 North, Vector3 Up) GetLocalEnuCesium(Vector3 position, double heading, double pitch = 0, double roll = 0)
1414
{
15-
var eastUp = CesiumTransformer.GetEastUp(position, heading);
16-
return (eastUp.East, new Vector3(0, 0, 0), eastUp.Up);
15+
var transform = SpatialConverter.EcefToEnu(position);
16+
17+
var east = Vector3.Normalize(new Vector3(transform.M11, transform.M12, transform.M13));
18+
var north = Vector3.Normalize(new Vector3(transform.M21, transform.M22, transform.M23));
19+
var up = Vector3.Normalize(new Vector3(transform.M31, transform.M32, transform.M33));
20+
21+
if (heading != 0)
22+
{
23+
east = Vector3.Normalize(Rotator.RotateVector(east, up, heading));
24+
north = Vector3.Normalize(Rotator.RotateVector(north, up, heading));
25+
}
26+
27+
if (pitch != 0)
28+
{
29+
north = Vector3.Normalize(Rotator.RotateVector(north, east, pitch));
30+
up = Vector3.Normalize(Rotator.RotateVector(up, east, pitch));
31+
}
32+
33+
if (roll != 0)
34+
{
35+
east = Vector3.Normalize(Rotator.RotateVector(east, north, roll));
36+
up = Vector3.Normalize(Rotator.RotateVector(up, north, roll));
37+
}
38+
39+
// Re-orthonormalize to avoid drift.
40+
east = Vector3.Normalize(east);
41+
north = Vector3.Normalize(Vector3.Cross(up, east));
42+
up = Vector3.Normalize(Vector3.Cross(east, north));
43+
44+
return (east, north, up);
1745
}
1846
}

src/GPUTileHandler.cs

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -256,26 +256,22 @@ private static AffineTransform GetInstanceTransform(Instance instance, bool UseS
256256
// roll : rotation around local Forward axis
257257
var positionVector3 = new Vector3((float)point.X, (float)point.Y, (float)point.Z);
258258

259-
var enu = EnuCalculator.GetLocalEnu(instance.Yaw, positionVector3);
259+
var enu = EnuCalculator.GetLocalEnuCesium(positionVector3, instance.Yaw, instance.Pitch, instance.Roll);
260+
260261
var east = Vector3.Normalize(enu.East);
261262
var up = Vector3.Normalize(enu.Up);
262-
var forward = Vector3.Normalize(Vector3.Cross(east, up));
263+
var forward = Vector3.Normalize(enu.North);
263264

264-
if (instance.Pitch != 0)
265-
{
266-
forward = Vector3.Normalize(Cesium.Rotator.RotateVector(forward, east, instance.Pitch));
267-
up = Vector3.Normalize(Cesium.Rotator.RotateVector(up, east, instance.Pitch));
268-
}
265+
// Convert basis from ECEF to glTF Y-up and build the quaternion in that coordinate system.
266+
var eastYUp = Vector3.Normalize(ToYUp(east));
267+
var upYUp = Vector3.Normalize(ToYUp(up));
268+
var forwardYUp = Vector3.Normalize(ToYUp(forward));
269269

270-
if (instance.Roll != 0)
271-
{
272-
east = Vector3.Normalize(Cesium.Rotator.RotateVector(east, forward, instance.Roll));
273-
up = Vector3.Normalize(Cesium.Rotator.RotateVector(up, forward, instance.Roll));
274-
}
275-
276-
forward = Vector3.Normalize(Vector3.Cross(east, up));
270+
// Orthonormalize (numerical stability).
271+
forwardYUp = Vector3.Normalize(Vector3.Cross(eastYUp, upYUp));
272+
upYUp = Vector3.Normalize(Vector3.Cross(forwardYUp, eastYUp));
277273

278-
var m4 = GetTransformationMatrix((east, new Vector3(0, 0, 0), up), forward);
274+
var m4 = GetTransformationMatrix((eastYUp, new Vector3(0, 0, 0), upYUp), forwardYUp);
279275
var res = Quaternion.CreateFromRotationMatrix(m4);
280276

281277
var position2 = new Vector3((float)(position.X - translation.X), (float)(position.Y - translation.Y), (float)(position.Z - translation.Z));
@@ -286,7 +282,7 @@ private static AffineTransform GetInstanceTransform(Instance instance, bool UseS
286282

287283
var transformation = new AffineTransform(
288284
scale,
289-
new Quaternion(-res.X, -res.Z, res.Y, res.W),
285+
res,
290286
position2);
291287
return transformation;
292288
}
@@ -337,22 +333,34 @@ private static PropertyTable GetPropertyTable(StructuralMetadataClass schemaClas
337333

338334
private static Matrix4x4 GetTransformationMatrix((Vector3 East, Vector3 North, Vector3 Up) enu, Vector3 forward)
339335
{
336+
// System.Numerics uses row-vector semantics (v * M). To map local axes (X,Y,Z) -> world axes,
337+
// the world basis vectors must be stored in the matrix rows.
340338
var m4 = new Matrix4x4();
339+
341340
m4.M11 = enu.East.X;
342-
m4.M21 = enu.East.Y;
343-
m4.M31 = enu.East.Z;
341+
m4.M12 = enu.East.Y;
342+
m4.M13 = enu.East.Z;
344343

345-
m4.M12 = enu.Up.X;
344+
m4.M21 = enu.Up.X;
346345
m4.M22 = enu.Up.Y;
347-
m4.M32 = enu.Up.Z;
346+
m4.M23 = enu.Up.Z;
348347

349-
m4.M13 = forward.X;
350-
m4.M23 = forward.Y;
348+
m4.M31 = forward.X;
349+
m4.M32 = forward.Y;
351350
m4.M33 = forward.Z;
351+
352+
m4.M44 = 1;
352353
return m4;
353354
}
355+
354356
private static Point ToYUp(Point position)
355357
{
356358
return new Point((double)position.X, (double)position.Z, (double)position.Y * -1);
357359
}
360+
361+
private static Vector3 ToYUp(Vector3 v)
362+
{
363+
return new Vector3(v.X, v.Z, -v.Y);
364+
}
365+
358366
}

0 commit comments

Comments
 (0)