import {type mat4, vec3, vec4} from 'gl-matrix'; import {Aabb} from './aabb.ts'; import {pointPlaneSignedDistance, rayPlaneIntersection} from '../util.ts'; export class Frustum { constructor(public points: vec4[], public planes: vec4[], public aabb: Aabb) { } public static fromInvProjectionMatrix(invProj: mat4, worldSize: number = 1, zoom: number = 0, horizonPlane?: vec4, flippedNearFar?: boolean): Frustum { const clipSpaceCorners = [ [-1, 1, -1, 1], [1, 1, -1, 1], [1, -1, -1, 1], [-1, -1, -1, 1], [-1, 1, 1, 1], [1, 1, 1, 1], [1, -1, 1, 1], [-1, -1, 1, 1] ]; // Globe and mercator projection matrices have different Y directions, hence we need different sets of indices. // This should be fixed in the future. const frustumPlanePointIndices = flippedNearFar ? [ [6, 5, 4], // near [0, 1, 2], // far [0, 3, 7], // left [2, 1, 5], // right [3, 2, 6], // bottom [0, 4, 5] // top ] : [ [0, 1, 2], // near [6, 5, 4], // far [0, 3, 7], // left [2, 1, 5], // right [3, 2, 6], // bottom [0, 4, 5] // top ]; const scale = Math.pow(2, zoom); // Transform frustum corner points from clip space to tile space, Z to meters const frustumCoords = clipSpaceCorners.map(v => unprojectClipSpacePoint(v, invProj, worldSize, scale)); if (horizonPlane) { // A horizon clipping plane was supplied. adjustFarPlaneByHorizonPlane(frustumCoords, frustumPlanePointIndices[0], horizonPlane, flippedNearFar); } const frustumPlanes = frustumPlanePointIndices.map((p: number[]) => { const a = vec3.sub([], frustumCoords[p[0]], frustumCoords[p[1]]); const b = vec3.sub([], frustumCoords[p[2]], frustumCoords[p[1]]); const n = vec3.normalize([], vec3.cross([], a, b)) as any; const d = -vec3.dot(n, frustumCoords[p[1]]); return n.concat(d); }); const min: vec3 = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]; const max: vec3 = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY]; for (const p of frustumCoords) { for (let i = 0; i < 3; i++) { min[i] = Math.min(min[i], p[i]); max[i] = Math.max(max[i], p[i]); } } return new Frustum(frustumCoords, frustumPlanes, new Aabb(min, max)); } } function unprojectClipSpacePoint(point: vec4 | number[], invProj: mat4, worldSize: number, scale: number): vec4 { const v = vec4.transformMat4([], point, invProj) as any; const s = 1.0 / v[3] / worldSize * scale; return vec4.mul(v, v, [s, s, 1.0 / v[3], s] as vec4); } /** * Modifies points in the supplied `frustumCoords` array so that the frustum's far plane only lies as far as the horizon, * which improves frustum culling effectiveness. * @param frustumCoords - Points of the frustum. * @param nearPlanePointsIndices - Which indices in the `frustumCoords` form the near plane. * @param horizonPlane - The horizon plane. */ function adjustFarPlaneByHorizonPlane(frustumCoords: vec4[], nearPlanePointsIndices: number[], horizonPlane: vec4, flippedNearFar: boolean): void { // For each of the 4 edges from near to far plane, // we find at which distance these edges intersect the given clipping plane, // select the maximal value from these distances and then we move // the frustum's far plane so that it is at most as far away from the near plane // as this maximal distance. const nearPlanePointsOffset = flippedNearFar ? 4 : 0; const farPlanePointsOffset = flippedNearFar ? 0 : 4; let maxDist = 0; const cornerRayLengths: number[] = []; const cornerRayNormalizedDirections: vec3[] = []; for (let i = 0; i < 4; i++) { const dir = vec3.sub([], frustumCoords[i + farPlanePointsOffset], frustumCoords[i + nearPlanePointsOffset]); const len = vec3.length(dir); vec3.scale(dir, dir, 1.0 / len); // normalize cornerRayLengths.push(len); cornerRayNormalizedDirections.push(dir); } for (let i = 0; i < 4; i++) { const dist = rayPlaneIntersection(frustumCoords[i + nearPlanePointsOffset], cornerRayNormalizedDirections[i], horizonPlane); if (dist !== null && dist >= 0) { maxDist = Math.max(maxDist, dist); } else { // Use the original ray length for rays parallel to the horizon plane, or for rays pointing away from it. maxDist = Math.max(maxDist, cornerRayLengths[i]); } } // Compute the near plane. // We use its normal as the "view vector" - direction in which the camera is looking. const nearPlaneNormalized = getNormalizedNearPlane(frustumCoords, nearPlanePointsIndices); // We also try to adjust the far plane position so that it exactly intersects the point on the horizon // that is most distant from the near plane. const idealFarPlaneDistanceFromNearPlane = getIdealNearFarPlaneDistance(horizonPlane, nearPlaneNormalized); if (idealFarPlaneDistanceFromNearPlane !== null) { const idealCornerRayLength = idealFarPlaneDistanceFromNearPlane / vec3.dot(cornerRayNormalizedDirections[0], nearPlaneNormalized); // dot(near plane, ray dir) is the same for all 4 corners maxDist = Math.min(maxDist, idealCornerRayLength); } for (let i = 0; i < 4; i++) { const targetLength = Math.min(maxDist, cornerRayLengths[i]); frustumCoords[i + farPlanePointsOffset] = [ frustumCoords[i + nearPlanePointsOffset][0] + cornerRayNormalizedDirections[i][0] * targetLength, frustumCoords[i + nearPlanePointsOffset][1] + cornerRayNormalizedDirections[i][1] * targetLength, frustumCoords[i + nearPlanePointsOffset][2] + cornerRayNormalizedDirections[i][2] * targetLength, 1, ] as vec4; } } /** * Returns the near plane equation with unit length direction. * @param frustumCoords - Points of the frustum. * @param nearPlanePointsIndices - Which indices in the `frustumCoords` form the near plane. */ function getNormalizedNearPlane(frustumCoords: vec4[], nearPlanePointsIndices: number[]): vec4 { const nearPlaneA = vec3.sub([], frustumCoords[nearPlanePointsIndices[0]], frustumCoords[nearPlanePointsIndices[1]]); const nearPlaneB = vec3.sub([], frustumCoords[nearPlanePointsIndices[2]], frustumCoords[nearPlanePointsIndices[1]]); const nearPlaneNormalized = [0, 0, 0, 0] as vec4; vec3.normalize(nearPlaneNormalized, vec3.cross([], nearPlaneA, nearPlaneB)) as any; nearPlaneNormalized[3] = -vec3.dot(nearPlaneNormalized, frustumCoords[nearPlanePointsIndices[0]]); return nearPlaneNormalized; } /** * Returns the ideal distance between the frustum's near and far plane so that the far plane only lies as far as the horizon. */ function getIdealNearFarPlaneDistance(horizonPlane: vec4, nearPlaneNormalized: vec4): number | null { // Normalize the horizon plane to unit direction const horizonPlaneLen = vec3.len(horizonPlane); const normalizedHorizonPlane = vec4.scale([], horizonPlane, 1 / horizonPlaneLen); // Project the view vector onto the horizon plane const projectedViewDirection = vec3.sub([], nearPlaneNormalized, vec3.scale([], normalizedHorizonPlane, vec3.dot(nearPlaneNormalized, normalizedHorizonPlane))); const projectedViewLength = vec3.len(projectedViewDirection); // projectedViewLength will be 0 if the camera is looking straight down if (projectedViewLength > 0) { // Find the radius and center of the horizon circle (the horizon circle is the intersection of the planet's sphere and the horizon plane). const horizonCircleRadius = Math.sqrt(1 - normalizedHorizonPlane[3] * normalizedHorizonPlane[3]); const horizonCircleCenter = vec3.scale([], normalizedHorizonPlane, -normalizedHorizonPlane[3]); // The horizon plane normal always points towards the camera. // Find the furthest point on the horizon circle from the near plane. const pointFurthestOnHorizonCircle = vec3.add([], horizonCircleCenter, vec3.scale([], projectedViewDirection, horizonCircleRadius / projectedViewLength)); // Compute this point's distance from the near plane. return pointPlaneSignedDistance(nearPlaneNormalized, pointFurthestOnHorizonCircle); } else { return null; } }