[Help] Fuzzy/Distorted Faces in Road Geometry Created from CatmullRom Curve – Three.js + Mapbox

Hi everyone, I’m working on a custom 3D geometry layer in a Mapbox + Three.js environment. I’m generating a road-like surface with left/right side surfaces by interpolating points along a CatmullRom curve. Geometry is constructed via cross-section points per segment.

The problem is that the faces appear distorted/Fuzzy or crumpled, even though the logic seems correct. The distortion gets worse when I use smaller values (due to precision issues), and sometimes face connectivity looks incorrect even after converting width units from meters to Mercator units.


  • I previously used a small scale = 0.0000001 to shrink geometry, but it led to face artifacts.
  • I switched to a meters-to-Mercator conversion function, but geometry was way off in the way it was created
  • I suspect the issue may lie in how I’m connecting faces across segments, or possibly in the way I compute left/right offset vectors per cross-section?

Code snippet of my createRoadGeometry() is attached below. Can someone check if the face-building logic is flawed?

Thanks in advance! :folded_hands:

Code:
metersToMercatorUnits() function
function metersToMercatorUnits(meters, latitude) {
const earthCircumference = 40075016.686;
return meters / (earthCircumference * Math.cos(latitude * Math.PI / 180));
}

Cross-section vertex building
const baseCenterLat = currentPoints[i]?.lngLat?.lat || 0;
const widthInMercator = metersToMercatorUnits(params.width, baseCenterLat);
const height = params.height;
const heightParallel = params.heightParallel;

const baseCenter = new THREE.Vector3().copy(point);
const mainLeft = baseCenter.clone()
.add(right.clone().multiplyScalar(-widthInMercator / 2))
.add(worldUp.clone().multiplyScalar(height));
const mainRight = baseCenter.clone()
.add(right.clone().multiplyScalar(widthInMercator / 2))
.add(worldUp.clone().multiplyScalar(height));
const leftOuter = baseCenter.clone()
.add(right.clone().multiplyScalar(-widthInMercator * 1.5))
.add(worldUp.clone().multiplyScalar(heightParallel));
const rightOuter = baseCenter.clone()
.add(right.clone().multiplyScalar(widthInMercator * 1.5))
.add(worldUp.clone().multiplyScalar(heightParallel));
mainVertices.push(mainLeft, mainRight);
leftVertices.push(mainLeft, leftOuter);
rightVertices.push(mainRight, rightOuter);

Face index pushing logic
if (i < segmentCount) {
const baseMain = i * 2;
const baseLeft = i * 2;
const baseRight = i * 2;

mainFaces.push(baseMain, baseMain + 1, baseMain + 3,
               baseMain, baseMain + 3, baseMain + 2);
leftFaces.push(baseLeft, baseLeft + 1, baseLeft + 3,
               baseLeft, baseLeft + 3, baseLeft + 2);
rightFaces.push(baseRight, baseRight + 1, baseRight + 3,
                baseRight, baseRight + 3, baseRight + 2);

}

BufferGeometry building section
const mainGeometry = new THREE.BufferGeometry();
const vertices = new Float32Array(mainVertices.length * 3);
mainVertices.forEach((v, i) => {
vertices[i * 3] = v.x;
vertices[i * 3 + 1] = v.y;
vertices[i * 3 + 2] = v.z;
});
mainGeometry.setAttribute(‘position’, new THREE.BufferAttribute(vertices, 3));
mainGeometry.setIndex(mainFaces);
mainGeometry.computeVertexNormals();

Road Function:

   createRoadGeometry: function(pathCurve) {
        if (!pathCurve || currentPoints.length < 2) return;

        // Define how many points to sample along the curve
        const segmentCount = Math.max((currentPoints.length - 1) * 15, 30);
        const wireframeInterval = Math.max(Math.ceil(segmentCount / ((currentPoints.length - 1) * 2)), 1);

        // Arrays for vertices and faces
        const mainVertices = [];
        const mainFaces = [];
        const leftVertices = [];
        const leftFaces = [];
        const rightVertices = [];
        const rightFaces = [];

        // Store wireframe points for each cross-section
        const wireframePoints = [];

        // Sample points along the curve
        for (let i = 0; i <= segmentCount; i++) {
            const t = i / segmentCount;
            const point = pathCurve.getPoint(t);
            const params = this.getInterpolatedParams(t, currentPoints);

            // Calculate the forward direction (tangent along the curve)
            const tangent = pathCurve.getTangent(t).normalize();

            // Up vector for Mapbox GL JS coordinate system
            const worldUp = new THREE.Vector3(0, 0, 1);

            // Calculate the right vector
            const right = new THREE.Vector3().crossVectors(tangent, worldUp).normalize();

            const scale = 0.0000001;
            // Scale factors for width and height
            const width = params.width * scale;
            const height = params.height * scale;
            const heightParallel = params.heightParallel * scale;

            // Center point at base level
            const baseCenter = new THREE.Vector3().copy(point);

            // Calculate cross-section points
            // Main road surface (gray middle)
            const mainLeft = new THREE.Vector3().copy(baseCenter)
                                                .add(new THREE.Vector3().copy(right).multiplyScalar(-width / 2))
                                                .add(new THREE.Vector3().copy(worldUp).multiplyScalar(height));

            const mainRight = new THREE.Vector3().copy(baseCenter)
                                                 .add(new THREE.Vector3().copy(right).multiplyScalar(width / 2))
                                                 .add(new THREE.Vector3().copy(worldUp).multiplyScalar(height));

            // Left side surface (yellow)
            const leftOuter = new THREE.Vector3().copy(baseCenter)
                                                 .add(new THREE.Vector3().copy(right).multiplyScalar(-width * 1.5))
                                                 .add(new THREE.Vector3().copy(worldUp).multiplyScalar(heightParallel));

            // Right side surface (yellow)
            const rightOuter = new THREE.Vector3().copy(baseCenter)
                                                  .add(new THREE.Vector3().copy(right).multiplyScalar(width * 1.5))
                                                  .add(new THREE.Vector3().copy(worldUp).multiplyScalar(heightParallel));

            // Bottom points for wireframe
            const bottomMainLeft = new THREE.Vector3().copy(baseCenter)
                                                      .add(new THREE.Vector3().copy(right).multiplyScalar(-width / 2));

            const bottomMainRight = new THREE.Vector3().copy(baseCenter)
                                                       .add(new THREE.Vector3().copy(right).multiplyScalar(width / 2));

            const bottomLeftOuter = new THREE.Vector3().copy(baseCenter)
                                                       .add(new THREE.Vector3().copy(right).multiplyScalar(-width * 1.5));

            const bottomRightOuter = new THREE.Vector3().copy(baseCenter)
                                                        .add(new THREE.Vector3().copy(right).multiplyScalar(width * 1.5));

            // Store vertices for each surface
            mainVertices.push(mainLeft, mainRight);
            leftVertices.push(mainLeft, leftOuter);
            rightVertices.push(mainRight, rightOuter);

            // Create faces (triangles) connecting this cross-section to the next
            if (i < segmentCount) {
                const base = i * 2;

                // Main surface faces
                mainFaces.push(base, base + 1, base + 3, base, base + 3, base + 2);

                // Left side faces
                leftFaces.push(base, base + 1, base + 3, base, base + 3, base + 2);

                // Right side faces
                rightFaces.push(base, base + 1, base + 3, base, base + 3, base + 2);
            }

            // Collect ALL possible wireframe points for this cross-section
            const crossSectionWireframe = [
                // Top lines
                mainLeft, mainRight,            // Top middle line
                mainLeft, leftOuter,            // Top left line to outer left
                mainRight, rightOuter,          // Top right line to outer right

                // Vertical connections
                bottomMainLeft, mainLeft,       // Bottom to top left
                bottomMainRight, mainRight,     // Bottom to top right
                bottomLeftOuter, leftOuter,     // Bottom to top outer left
                bottomRightOuter, rightOuter,   // Bottom to top outer right

                // Bottom lines
                bottomMainLeft, bottomMainRight,    // Bottom middle line
                bottomMainLeft, bottomLeftOuter,    // Bottom middle to left outer
                bottomMainRight, bottomRightOuter,  // Bottom middle to right outer
                bottomLeftOuter, bottomRightOuter   // Bottom outer line
            ];

            // Only store wireframe points for selected intervals
            if (settings.wireframeMode && (i % wireframeInterval === 0 || i === 0 || i === segmentCount)) {
                wireframePoints.push(crossSectionWireframe);
            }
        }

        // Create wireframe lines
        if (settings.wireframeMode) {
            for (let j = 0; j < wireframePoints.length; j++) {
                const points = wireframePoints[j];

                // Draw all lines for the current cross-section
                for (let k = 0; k < points.length; k += 2) {
                    this.createWireframeLine(points[k], points[k + 1]);
                }

                // Connect adjacent cross-sections
                if (j > 0) {
                    const prevPoints = wireframePoints[j - 1];
                    const currentPoints = wireframePoints[j];

                    // Systematically connect corresponding points between cross-sections
                    const connectPairs = [
                        [0, 0], // Top middle left
                        [1, 1], // Top middle right
                        [2, 2], // Top left outer
                        [3, 3], // Top right outer
                        [4, 4], // Bottom middle left
                        [5, 5], // Bottom middle right
                        [6, 6], // Bottom left outer
                        [7, 7], // Bottom outer line left
                        [8, 8], // Bottom middle left
                        [9, 9], // Bottom middle right
                        [10, 10], // Bottom left outer
                        [11, 11], // Bottom right outer
                    ];

                    connectPairs.forEach(([prevIndex, currIndex]) => {
                        this.createWireframeLine(
                            prevPoints[prevIndex],
                            currentPoints[currIndex]
                        );
                    });
                }
            }
        }

        return {
            mainVertices, mainFaces,
            leftVertices, leftFaces,
            rightVertices, rightFaces
        };
    },

@Fennec sorry to tag you on this, but I’ve been following your comments on other threads and thought you might have some insight or advice on this geometry issue :innocent: :folded_hands:

Do you find the centerpoint first, and then build the geometry relative to that,
or are you defining the points in world space?
if yr computing the points in world space, you might be hitting the limits of Float32 on the coordinates.
Vertices are stored in Float32
Object.position is js 64 bit double

3 Likes

see

1 Like

thank you @manthrax , I tried the option you shared, but unfortunately it didn’t resolve the issue. I tried building the geometry relative to a center point and also experimented with anchoring the vector point and using an array of coordinates.

The problem seems to arise specifically with small-scale geometry, when it’s scaled down to something like 0.00001, I start noticing a “zigzag-like” behavior. Interestingly, this issue disappears when the geometry is scaled to 0.1 or 1.

I suspect this behavior might be caused by continuous re-rendering at such a small scale, but I’m not entirely sure. Do you have any advice or advice on how to address this?

@hofk Thank you! I actually used that as the base for my initial setup. However, the issue seems to be related to scale. I believe it’s a rendering issue, as the map appears to re-render when navigating or moving around, which in turn causes the geometry to re-render as well. This behavior is much more noticeable at smaller scales, whereas when the geometry is significantly larger than the map, the issue is no longer visible.

could scale all other stuff 100000 times…so you don’t have to scale this one…

2 Likes

i can not scale the map, and wont give correct geometry data either @towrabbit :slight_smile:

why not. scale whole things does not make any diffirence. the main idea is keep the precision. can you create a demo that produce this issue, I could help with it. go to WebGL Report. take a screenshot.this website could get the device webgl2 basic stats/ supports and something related.

2 Likes

@hofk @manthrax @towrabbit ,
so since I am using a geometry coordinate system and also working within a map coordinate system, scaling down in real-time on the map makes this zigzag appearance. I am thinking that this could be due to something similar to the frame’s bitrate?, for example like how reducing the resolution of a large video causes pixelation?

I suspect this could be the issue, but I’m not entirely sure what the best approach would be to fix it. Do you have any suggestions?

I think that it is like that.

Math in Javascript is 64 bit.

Math in the renderer/glsl itself is 32bit.

If you store the world space coordinates in the geometry itself… you’re limited to 32 bit precision, so if those numbers are really large or really small, you start to see jitter. (I think this is what you’re doing, but I’m not positive without seeing the code running somewhere.)

But… you can instead store the location/center of the mesh itself in the threejs mesh.position (64bit)… then your vertices can be stored relative to that position, and therefore fit into the “bitrate” of the renderer (32bit).
This also makes scaling/rotation/movement more convenient because you can just change mesh.position/rotation/scale instead of recomputing vertices… it lets you control the origin (pivot point) of the mesh… and if you scale the mesh, it will scale relative to that pivot, intuitively.

What towrabbit is proposing would also work, but would require translating units from worldspace to the scaled space, and keeping track of that or doing all your calculations in that space rather than the more natural “world space”.

1 Like

thank you @manthrax. :folded_hands:
i’ve tried your suggestion and here is a breakdown:

  • Geometry coordinates are correctly computed in meters relative to a Mercator anchor point.
    -I’ve converted all geometry dimensions (width, height, heightParallel) to Mercator units using meterInMercatorCoordinateUnits().
    -i’ve tried scaling the geometry after extrusion, or building it in relative coordinates and offsetting via mesh.position.copy(anchorCoord).
  • i’ve tested both **local mesh offsetting and fully absolute positioning, neither resolving the visual scale issues.
  • i’ve ruled out projection matrix mismatch, camera projection is correctly updated via camera.projectionMatrix.elements = matrix from Mapbox.

Still, geometry appears either hundreds of times larger than expected or when scaled down, it would be fuzzy and Jittery, with directional distortion and inconsistent wireframe behavior.

not sure if there are any other ideas to try out there :sweat_smile:

1 Like

I think it’s probably a good point to make a minimal reproducable codepen showing the issue and we can probably fix it for you.

4 Likes

Mapbox has an inbuilt method for this…

const mercatorUnit = mapboxgl.MercatorCoordinate.fromLngLat([longitude, latitude], meters);

If you wanted to calculate this manually, Mapbox also has inbuilt methods to project and unproject lat lng values to and from mercator coordinates which can be used to calculate metric to mercator unit conversions…

const map = new mapboxgl.Map({ /* map options */ });

function metersToMercatorUnits(meters, latitude, longitude) {

    const startPoint = map.project([longitude, latitude]);

    const earthCircumference = 40075016.686; // Meters
    const degreesPerMeter = 360 / earthCircumference;
    const deltaLatitude = meters * degreesPerMeter;

    const endPoint = map.project([longitude, latitude + deltaLatitude]);

    return Math.abs(endPoint.y - startPoint.y);
}

Or you can use a library like turf to handle these conversions…

import * as turf from '@turf/turf';

function metersToMercatorUnits(meters, latitude, longitude) {
    const startPoint = turf.point([longitude, latitude]);
    const destination = turf.destination(startPoint, meters / 1000, 0); // Move north by `meters`
    const endPoint = destination.geometry.coordinates;

    // Use Mapbox's project method to get Mercator units
    const startMercator = map.project([longitude, latitude]);
    const endMercator = map.project(endPoint);

    return Math.abs(endMercator.y - startMercator.y);
}

Your best bet may be to first projection plot each of your initial curve points into the map space and then use something like the above methods to work out metric to mercator conversions…

something about your metersToMercatorUnits seems off, I asked qwen…

  • Incorrect Scaling: The function divides meters by the circumference scaled by the cosine of the latitude. However, this does not directly produce Mercator units. Instead, it produces a fraction of the Earth’s circumference, which is not the same as the Mercator projection’s unit scale.
2 Likes

Thank you so much for the help! @manthrax I’ve simplified the geometry to better isolate the issue and tested it in plain HTML/JavaScript . everything was working perfectly there. However, once I switch to React, the geometry starts shaking again. I’ve attached the working HTML version for reference. Any ideas on what might be causing the instability in the React implementation? i followed @towrabbit and @Lawrence3DPK advice with the conversion, not sure what else to try :sweat_smile: or if there is something im missing
mapbox_threejs_geometry.html (7.8 KB)

I think we won’t be able to see your mapbox setup as it requires an api key, which you probably don’t want to expose… Wondering if we can’t make a “community” account on mapbox to get a token we can use for testong

1 Like

thank you @Lawrence3DPK is there a person who takes care of setting up a community account? sorry im new to the forum :see_no_evil_monkey:

I’ve setup a maplibre environment for you, which is open source and doesn’t need an api key here…

https://codepen.io/forerunrun/pen/XJWZjwQ

hopefully this can allow people here to help debug, it’s not quite working but the skeleton of what you’ve done already is there in a live editable env…

the docs on adding a three.js layer seem pretty much the same in both mapbox and maplibre so the code should translate over quite easily…

mapbox:

maplibre:

I may have some more time in the week to look into this but for now hopefully this serves as a test bed to allow others to experiment with a fix…

1 Like