Refactor spline road mesh generation by simplifying boundary handling and capping logic

This commit is contained in:
2026-05-13 18:28:48 +02:00
parent a8c62adfe6
commit 16bff3a42a

View File

@@ -51,17 +51,11 @@ interface RoadStation {
tangent: Vec3;
}
interface EdgeRoadStation extends RoadStation {
edgeHeightScale: number;
}
interface EdgeProfilePoint {
offset: number;
heightOffset: number;
}
const DISTANCE_EPSILON = 1e-6;
function cloneVec3(vector: Vec3): Vec3 {
return {
x: vector.x,
@@ -339,134 +333,6 @@ function buildVisibleRoadStationRuns(
return runs;
}
function getClipBoundaryDistances(
clipIntervals: readonly SplineCorridorPathClipInterval[]
): number[] {
return clipIntervals.flatMap((clipInterval) => [
clipInterval.startDistance,
clipInterval.endDistance
]);
}
function getEdgeJunctionTaperDistance(edge: ScenePathRoadEdgeSettings): number {
return Math.min(1.25, Math.max(0.35, edge.width * 1.5));
}
function interpolateStationInRun(
run: readonly RoadStation[],
distance: number
): RoadStation {
const first = run[0]!;
const last = run[run.length - 1]!;
if (distance <= first.distance + DISTANCE_EPSILON) {
return cloneVec3Station(first);
}
if (distance >= last.distance - DISTANCE_EPSILON) {
return cloneVec3Station(last);
}
for (let index = 0; index < run.length - 1; index += 1) {
const start = run[index]!;
const end = run[index + 1]!;
if (
distance >= start.distance - DISTANCE_EPSILON &&
distance <= end.distance + DISTANCE_EPSILON
) {
return interpolateStation(start, end, distance);
}
}
return cloneVec3Station(last);
}
function cloneVec3Station(station: RoadStation): RoadStation {
return {
center: cloneVec3(station.center),
distance: station.distance,
tangent: cloneVec3(station.tangent)
};
}
function getNearestClipBoundaryDistance(
distance: number,
boundaries: readonly number[]
): number | null {
let nearestDistance: number | null = null;
for (const boundary of boundaries) {
const boundaryDistance = Math.abs(distance - boundary);
if (nearestDistance === null || boundaryDistance < nearestDistance) {
nearestDistance = boundaryDistance;
}
}
return nearestDistance;
}
function getEdgeHeightScale(
distance: number,
boundaries: readonly number[],
taperDistance: number
): number {
const nearestDistance = getNearestClipBoundaryDistance(distance, boundaries);
if (nearestDistance === null || nearestDistance >= taperDistance) {
return 1;
}
return Math.min(1, Math.max(0, nearestDistance / taperDistance));
}
function pushUniqueDistance(distances: number[], distance: number) {
if (
Number.isFinite(distance) &&
!distances.some(
(candidate) => Math.abs(candidate - distance) <= DISTANCE_EPSILON
)
) {
distances.push(distance);
}
}
function buildEdgeStationRunWithJunctionTapers(options: {
run: readonly RoadStation[];
clipIntervals: readonly SplineCorridorPathClipInterval[];
edge: ScenePathRoadEdgeSettings;
}): EdgeRoadStation[] {
const runStart = options.run[0]?.distance ?? 0;
const runEnd = options.run[options.run.length - 1]?.distance ?? 0;
const taperDistance = getEdgeJunctionTaperDistance(options.edge);
const clipBoundaries = getClipBoundaryDistances(options.clipIntervals).filter(
(distance) =>
distance >= runStart - DISTANCE_EPSILON &&
distance <= runEnd + DISTANCE_EPSILON
);
const distances = options.run.map((station) => station.distance);
for (const boundary of clipBoundaries) {
pushUniqueDistance(distances, boundary);
pushUniqueDistance(
distances,
Math.min(runEnd, Math.max(runStart, boundary - taperDistance))
);
pushUniqueDistance(
distances,
Math.min(runEnd, Math.max(runStart, boundary + taperDistance))
);
}
return distances
.sort((left, right) => left - right)
.map((distance) => ({
...interpolateStationInRun(options.run, distance),
edgeHeightScale: getEdgeHeightScale(distance, clipBoundaries, taperDistance)
}));
}
function createGeometryFromMeshData(meshData: SplineRoadMeshData) {
const geometry = new BufferGeometry();
geometry.setAttribute(
@@ -527,6 +393,8 @@ function addEdgeProfileCaps(options: {
profileVertexCount: number;
vertexOffset: number;
stationCount: number;
capStart: boolean;
capEnd: boolean;
}) {
if (options.profileVertexCount < 3 || options.stationCount < 2) {
return;
@@ -536,16 +404,33 @@ function addEdgeProfileCaps(options: {
options.vertexOffset + (options.stationCount - 1) * options.profileVertexCount;
for (let index = 1; index < options.profileVertexCount - 1; index += 1) {
// Start cap faces backward along the spline; end cap faces forward.
options.indices.push(
options.vertexOffset,
options.vertexOffset + index + 1,
options.vertexOffset + index
);
options.indices.push(lastRow, lastRow + index, lastRow + index + 1);
if (options.capStart) {
// Start cap faces backward along the spline.
options.indices.push(
options.vertexOffset,
options.vertexOffset + index + 1,
options.vertexOffset + index
);
}
if (options.capEnd) {
// End caps face forward, but clipped junction ends stay open for junction edge handoff.
options.indices.push(lastRow, lastRow + index, lastRow + index + 1);
}
}
}
function isClipBoundaryDistance(
distance: number,
clipIntervals: readonly SplineCorridorPathClipInterval[]
): boolean {
return clipIntervals.some(
(clipInterval) =>
Math.abs(distance - clipInterval.startDistance) <= 1e-6 ||
Math.abs(distance - clipInterval.endDistance) <= 1e-6
);
}
export function buildSplineRoadMeshData(options: {
path: SplineRoadPathLike;
terrains?: readonly Terrain[];
@@ -683,12 +568,7 @@ export function buildSplineRoadEdgeMeshData(options: {
);
let stationCount = 0;
for (const rawRun of stationRuns) {
const run = buildEdgeStationRunWithJunctionTapers({
run: rawRun,
clipIntervals: options.clipIntervals ?? [],
edge
});
for (const run of stationRuns) {
const runVertexOffset = positions.length / 3;
for (let stationIndex = 0; stationIndex < run.length; stationIndex += 1) {
@@ -721,7 +601,7 @@ export function buildSplineRoadEdgeMeshData(options: {
positions.push(
vertex.x,
vertex.y + profilePoint.heightOffset * station.edgeHeightScale,
vertex.y + profilePoint.heightOffset,
vertex.z
);
uvs.push(profileIndex / profileLastIndex, station.distance);
@@ -743,11 +623,18 @@ export function buildSplineRoadEdgeMeshData(options: {
}
if (edge.kind !== "softShoulder") {
const clipIntervals = options.clipIntervals ?? [];
addEdgeProfileCaps({
indices,
profileVertexCount: profile.length,
vertexOffset: runVertexOffset,
stationCount: run.length
stationCount: run.length,
capStart: !isClipBoundaryDistance(run[0]!.distance, clipIntervals),
capEnd: !isClipBoundaryDistance(
run[run.length - 1]!.distance,
clipIntervals
)
});
}