Fixed normals for road geometry

This commit is contained in:
2026-08-15 08:29:22 +03:00
parent c38ac8eff3
commit 5ea7a6bde8
3 changed files with 124 additions and 30 deletions
@@ -19,7 +19,10 @@ The template from `getRoadTemplate(cfg)`:
- **Y**: ∈ [-roadThickness/2, +roadThickness/2]. Maps directly to world vertical offset from the road surface at that position.
- **Z**: ∈ [-1, 0] (fallback box; loaded files may differ but must span exactly 1 unit of distance along the road).
- **UVs**: span (0,0)(1,1) over X/Z extents on each face.
- **Normals**: preserved through rigid rotation during transformation.
- **Normals**: the template axes are mapped explicitly during the bend
(+X → outer-curb direction, +Y → up, -Z → travel), because the wedge
bend is a *reflection* of the template (see §5.7) and therefore cannot
be represented by a single rotation around Y.
If the template file is missing, the fallback is a 6-face unit box (24 verts, 36 indices, X∈[0,1], Y∈[-thick/2,+thick/2], Z∈[-1,0]).
@@ -62,8 +65,8 @@ The single implementation lives in `roadlib/RoadGeometryLib.cpp`
(namespace `RoadGeometryLib`); the public `RoadSystem` statics forward
to it. The transformed wedge strip is already a closed tube (the
template supplies top, bottom and curb faces), so it is appended to
the output verbatim — slab extrusion (§8) applies to straight
segments only. `RoadGeometryLib` also provides
the output with winding reversed (§5.9) — slab extrusion (§8) applies
to straight segments only. `RoadGeometryLib` also provides
`loadTemplateFromMesh()` (template loading per §2) and
`makeFallbackTemplate()`.
@@ -283,12 +286,16 @@ v.uv.x = (d <= L1) ? halfEdgeU(H1, graph, L1 - d)
: halfEdgeU(H2, graph, d - L1)
v.uv.y = v.uv.y * width(d) + in1
// Normal — rotate template-forward (-Z) to segment direction by the
// SIGNED angle around Y:
segDir = (d <= L1) ? dir1 : dir2
theta = atan2(-segDir.x, -segDir.z)
Ogre::Quaternion q(Ogre::Radian(theta), Ogre::Vector3::UNIT_Y);
v.normal = q * v.normal;
// Normal — map the template axes onto the bent world frame explicitly.
// The bend is a REFLECTION of the template: travel is -dir1 on the first
// half-edge and the outer curb runs along +offset(d), which is
// -roadRight(dir2) on the second half-edge. A single rotation around Y
// would invert those axes, so the template axes are mapped one by one:
lateral = normalize(offset(d)) // +X -> outer-curb direction
travel = (d <= L1) ? -dir1 : dir2 // -Z -> travel direction
v.normal = lateral * v.normal.x
+ Vector3(0, v.normal.y, 0)
+ travel * (-v.normal.z);
```
Since Phase 1 guarantees d ∈ [0, L] (we use exactly ceil(L) copies and
@@ -303,14 +310,42 @@ at adjacent distances d and d+ε map to adjacent world positions. **No gap
opens at the outer corner.**
The travel direction `dir(d)` is piecewise (dir1 → dir2 at the node), but
`dir(d)` only affects the normal rotation and UV computation — it does
not affect vertex positions. The cross-section orientation is driven
`dir(d)` only affects the normal mapping (§5.7) and UV computation — it
does not affect vertex positions. The cross-section orientation is driven
entirely by the continuous `offset(d)`.
The centerline has a sharp corner at O, but the centerline edge is the
**inside** of the bend, shared with adjacent wedges. No fill is needed
there.
### 5.9 The Bend Is a Reflection (Normals and Winding)
The template's local frame — X = outer-curb lateral, Y = up, Z =
longitudinal with travel along -Z — is **left-handed** (X × Y = +Z =
-forward), while the world road frame (outer-curb lateral, up, travel)
is **right-handed** (lateral × up = +travel). The position mapping
```
world = center(d) + offset(d) * x + (0, y, 0)
```
is therefore a *reflection* of the template, not a rotation. That has
two consequences, both handled explicitly:
* **Normals** cannot be recovered by a single rotation around Y. The
template-forward -Z does not map to `dir1`/`dir2`: travel is -dir1 on
the first half-edge (midpoint → node) and the outer curb runs along
`offset(d)`, which equals -roadRight(dir2) on the second half-edge.
`transformWedgeVertices` therefore maps the template axes one by one
(§5.7): +X → normalize(offset(d)), +Y → +Y, -Z → travel.
* **Winding** comes out inverted — every triangle's front face flips to
the back. `buildWedgeGeometry` reverses each triangle's index order
before appending the strip (§8.3) so the closed solid is front-facing
outward; otherwise the top surface would be culled by back-face
culling and the underside (with downward normals) would show through —
the "inverted normals" symptom reported when nodes are repositioned.
## 6. Phase 3 — Center Seam Shifting
**Function**: `static void shiftSeamVertices(Procedural::TriangleBuffer &strip, const RoadWedge &wedge, const RoadGraph &graph)`
@@ -437,10 +472,12 @@ Where `refPoint` is the centroid of `centerSurf`.
and curb faces, and `appendTemplateCopy` drops only the template
caps and the centerline wall (the open ends butt exactly against the
neighbouring pieces at the edge midpoints, the open centerline side
against the adjacent wedge). The transformed strip is appended to
the output verbatim. (Re-extruding it additionally stacked coplanar
sheets at the strip's center surface and doubled the slab
thickness.)
against the adjacent wedge). Because the bend is a reflection of the
template, each triangle's winding is reversed before the strip is
appended, so the closed solid is front-facing outward (its top
surface survives back-face culling). (Re-extruding it additionally
stacked coplanar sheets at the strip's center surface and doubled the
slab thickness.)
- **Segment**: the center-surface band (§7) is flat, so it is passed
to `extrudeToSlab`, keeping the far-end edge open (it meets the
neighbour node's piece exactly).
@@ -458,6 +495,8 @@ interior and get no skirts — they meet adjacent road pieces.
| ROAD_SEAM_OVERLAP on segments (§7) | Center gap for dead-end nodes | Segment band |
| Center seam shifting (§6) | Center hole where >2 wedges meet | Phase 3 |
| Slab extrusion (§8) | Road must be a closed solid | Segments |
| Explicit normal mapping (§5.7) | Outer-curb wall normal inverted on the second half-edge | Phase 2 |
| Winding reversal (§5.9, §8.3) | Inside-out wedge (top surface culled; "inverted normals") | buildWedgeGeometry |
## 10. Internal Functions (Testable)
@@ -535,8 +574,10 @@ zone it is `K - center(d)`.
`road_geometry_overlap_test`, CTest `roadGeometryOverlapTest`) builds
the demo's ABC graph headlessly — flat, corner node raised/lowered,
endpoints raised — and fails when any wedge or segment slab contains
coplanar-overlapping or piercing triangle pairs, or when the flat
wedge's slab thickness exceeds roadThickness/2. Optional arguments
coplanar-overlapping or piercing triangle pairs, when the flat wedge's
slab thickness exceeds roadThickness/2, or when any triangle's stored
vertex normal disagrees with its winding (`dot(geometric, stored) < 0`),
which indicates an inside-out (reflected) face. Optional arguments
`Ax Ay Az Bx By Bz Cx Cy Cz` analyse a single custom configuration
(useful when debugging geometry reported by the demo).
@@ -594,9 +635,21 @@ benefit for the narrow blend zone (W ≈ 0.2 units).
`dir(d)` is piecewise (dir1 for d≤L1, dir2 for d>L1) because the
centerline is a polyline with a sharp corner. This is correct for
road intersections. `dir(d)` only affects normal rotation and UV
road intersections. `dir(d)` only affects the normal mapping and UV
lookup — vertex positions are driven by the continuous `offset(d)`.
### Why map the template axes instead of rotating normals?
The template frame (X = outer-curb lateral, Y = up, -Z = forward) is
left-handed while the bent world frame (lateral, up, travel) is
right-handed, so the bend is a reflection and no rotation around Y can
map the template normals onto the surface. Rotating the normal toward
`dir(d)` inverted the outer-curb wall normal on the second half-edge
(where the curb runs along -roadRight(dir2)) and the travel axis on the
first half-edge (travel = -dir1), producing inside-out faces. Mapping
the axes one by one (§5.7) and reversing the winding (§5.9) restores a
correctly oriented closed solid.
### Template mesh is finally used
The current implementation ignores the template from M5.3. This
@@ -432,12 +432,27 @@ void transformWedgeVertices(Procedural::TriangleBuffer &strip,
: halfEdgeU(h2, graph, d - L1);
v.mUV.y = v.mUV.y * widthD + in1;
/* Normal rotation. */
const Ogre::Vector3 &segDir = (d <= L1) ? dir1 : dir2;
float theta = std::atan2(-segDir.x, -segDir.z);
Ogre::Quaternion q(Ogre::Radian(theta),
Ogre::Vector3::UNIT_Y);
Ogre::Vector3 n = q * v.mNormal;
/*
* Normal. The wedge bend is a reflection of the template:
* the outer curb runs along +offset(d), which is
* -roadRight(dir2) on the second half-edge, and travel is
* -dir1 on the first half-edge. A single rotation around Y
* therefore cannot map the template normals onto the bent
* surface (it would invert the outer-curb wall normal on the
* second half-edge and the longitudinal axis on the first).
* Map the template axes explicitly instead: +X -> outer-curb
* direction, +Y -> up, -Z -> travel.
*/
Ogre::Vector3 lateral = off;
if (lateral.length() < 1e-4f)
lateral = (d <= L1) ? roadRightVec(dir1)
: -roadRightVec(dir2);
else
lateral.normalise();
Ogre::Vector3 travel = (d <= L1) ? -dir1 : dir2;
Ogre::Vector3 n = lateral * v.mNormal.x +
Ogre::Vector3(0, v.mNormal.y, 0) +
travel * (-v.mNormal.z);
v.mPosition = Ogre::Vector3(worldXZ.x, worldY, worldXZ.z);
v.mNormal = n;
@@ -618,15 +633,28 @@ bool buildWedgeGeometry(const RoadWedge &wedge,
* body (the template supplies top, bottom and curb faces; the
* template caps and centerline wall are dropped by
* appendTemplateCopy and butt exactly against the neighbouring
* pieces), so it is appended verbatim. Re-extruding it into a
* pieces), so it is appended as-is. Re-extruding it into a
* slab would double the road thickness and stack coplanar sheets
* at the strip's center surface.
*/
int base = (int)out.getVertices().size();
for (const auto &v : strip.getVertices())
out.getVertices().push_back(v);
for (int idx : strip.getIndices())
out.getIndices().push_back(base + idx);
/*
* The wedge bend is a reflection of the template (the outer curb
* runs along -roadRight on the second half-edge), so the template
* winding comes out inverted. Reverse each triangle so the closed
* road solid is front-facing outward (and back-face culling keeps
* the top surface).
*/
const std::vector<int> &si = strip.getIndices();
out.getIndices().reserve(out.getIndices().size() + si.size());
for (size_t t = 0; t + 2 < si.size(); t += 3) {
out.getIndices().push_back(base + si[t]);
out.getIndices().push_back(base + si[t + 2]);
out.getIndices().push_back(base + si[t + 1]);
}
return true;
}
@@ -164,6 +164,7 @@ static bool segTriPierce(const Vector3 &p0, const Vector3 &p1,
struct Analysis {
int coplanar = 0;
int crossing = 0;
int invertedNormal = 0;
bool nan = false;
Vector3 mn = Vector3(FLT_MAX, FLT_MAX, FLT_MAX);
Vector3 mx = Vector3(-FLT_MAX, -FLT_MAX, -FLT_MAX);
@@ -197,6 +198,17 @@ static Analysis analyze(const Procedural::TriangleBuffer &buf,
float l0 = n0.length();
if (l0 > 1e-8f)
n0 /= l0;
/* The stored vertex normal must agree with the winding: a
* reflection in the wedge bend would otherwise flip the face
* inside-out (inverted shading / back-face culling). */
const Vector3 &sn = verts[(size_t)indices[i * 3]].mNormal;
if (sn.squaredLength() > 1e-6f && n0.dotProduct(sn) < 0.0f) {
if (res.invertedNormal < 20)
printf(" INVERTED-NORMAL tri %zu near "
"(%.2f,%.2f,%.2f)\n",
i, t0[0].x, t0[0].y, t0[0].z);
++res.invertedNormal;
}
for (size_t j = i + 1; j < nTri; ++j) {
Vector3 t1[3];
for (int k = 0; k < 3; ++k)
@@ -245,8 +257,8 @@ static Analysis analyze(const Procedural::TriangleBuffer &buf,
}
}
}
printf(" coplanar-overlap pairs: %d crossing pairs: %d\n",
res.coplanar, res.crossing);
printf(" coplanar-overlap pairs: %d crossing pairs: %d inverted-normal faces: %d\n",
res.coplanar, res.crossing, res.invertedNormal);
return res;
}
@@ -261,7 +273,8 @@ static bool checkPiece(bool built, const char *name,
Analysis a = analyze(buf, name);
if (out)
*out = a;
bool ok = !a.nan && a.coplanar == 0 && a.crossing == 0;
bool ok = !a.nan && a.coplanar == 0 && a.crossing == 0 &&
a.invertedNormal == 0;
if (!ok)
printf(" *** %s FAILED ***\n", name);
return ok;