import { Shape } from '../shapes/Shape' import { ConvexPolyhedron } from '../shapes/ConvexPolyhedron' import { Vec3 } from '../math/Vec3' import { Utils } from '../utils/Utils' import type { AABB } from '../collision/AABB' import type { Quaternion } from '../math/Quaternion' export type HeightfieldOptions = ConstructorParameters[1] type HeightfieldPillar = { convex: any offset: any } /** * Heightfield shape class. Height data is given as an array. These data points are spread out evenly with a given distance. * @todo Should be possible to use along all axes, not just y * @todo should be possible to scale along all axes * @todo Refactor elementSize to elementSizeX and elementSizeY * * @example * // Generate some height data (y-values). * const data = [] * for (let i = 0; i < 1000; i++) { * const y = 0.5 * Math.cos(0.2 * i) * data.push(y) * } * * // Create the heightfield shape * const heightfieldShape = new CANNON.Heightfield(data, { * elementSize: 1 // Distance between the data points in X and Y directions * }) * const heightfieldBody = new CANNON.Body({ shape: heightfieldShape }) * world.addBody(heightfieldBody) */ export class Heightfield extends Shape { /** * An array of numbers, or height values, that are spread out along the x axis. */ data: number[][] /** * Max value of the data points in the data array. */ maxValue: number | null /** * Minimum value of the data points in the data array. */ minValue: number | null /** * World spacing between the data points in X and Y direction. * @todo elementSizeX and Y * @default 1 */ elementSize: number /** * @default true */ cacheEnabled: boolean pillarConvex: ConvexPolyhedron pillarOffset: Vec3 private _cachedPillars: { [key: string]: HeightfieldPillar } /** * @param data An array of numbers, or height values, that are spread out along the x axis. */ constructor( data: number[][], options: { /** * Max value of the data points in the data array. * Will be computed automatically if not given. */ maxValue?: number | null /** * Minimum value of the data points in the data array. * Will be computed automatically if not given. */ minValue?: number | null /** * World spacing between the data points in X direction. */ elementSize?: number } = {} ) { options = Utils.defaults(options, { maxValue: null, minValue: null, elementSize: 1, }) super({ type: Shape.types.HEIGHTFIELD }) this.data = data this.maxValue = options.maxValue! this.minValue = options.minValue! this.elementSize = options.elementSize! if (options.minValue === null) { this.updateMinValue() } if (options.maxValue === null) { this.updateMaxValue() } this.cacheEnabled = true this.pillarConvex = new ConvexPolyhedron() this.pillarOffset = new Vec3() this.updateBoundingSphereRadius() // "i_j_isUpper" => { convex: ..., offset: ... } // for example: // _cachedPillars["0_2_1"] this._cachedPillars = {} } /** * Call whenever you change the data array. */ update(): void { this._cachedPillars = {} } /** * Update the `minValue` property */ updateMinValue(): void { const data = this.data let minValue = data[0][0] for (let i = 0; i !== data.length; i++) { for (let j = 0; j !== data[i].length; j++) { const v = data[i][j] if (v < minValue) { minValue = v } } } this.minValue = minValue } /** * Update the `maxValue` property */ updateMaxValue(): void { const data = this.data let maxValue = data[0][0] for (let i = 0; i !== data.length; i++) { for (let j = 0; j !== data[i].length; j++) { const v = data[i][j] if (v > maxValue) { maxValue = v } } } this.maxValue = maxValue } /** * Set the height value at an index. Don't forget to update maxValue and minValue after you're done. */ setHeightValueAtIndex(xi: number, yi: number, value: number): void { const data = this.data data[xi][yi] = value // Invalidate cache this.clearCachedConvexTrianglePillar(xi, yi, false) if (xi > 0) { this.clearCachedConvexTrianglePillar(xi - 1, yi, true) this.clearCachedConvexTrianglePillar(xi - 1, yi, false) } if (yi > 0) { this.clearCachedConvexTrianglePillar(xi, yi - 1, true) this.clearCachedConvexTrianglePillar(xi, yi - 1, false) } if (yi > 0 && xi > 0) { this.clearCachedConvexTrianglePillar(xi - 1, yi - 1, true) } } /** * Get max/min in a rectangle in the matrix data * @param result An array to store the results in. * @return The result array, if it was passed in. Minimum will be at position 0 and max at 1. */ getRectMinMax(iMinX: number, iMinY: number, iMaxX: number, iMaxY: number, result: number[] = []): void { // Get max and min of the data const data = this.data // Set first value let max = this.minValue! for (let i = iMinX; i <= iMaxX; i++) { for (let j = iMinY; j <= iMaxY; j++) { const height = data[i][j] if (height > max) { max = height } } } result[0] = this.minValue! result[1] = max } /** * Get the index of a local position on the heightfield. The indexes indicate the rectangles, so if your terrain is made of N x N height data points, you will have rectangle indexes ranging from 0 to N-1. * @param result Two-element array * @param clamp If the position should be clamped to the heightfield edge. */ getIndexOfPosition(x: number, y: number, result: number[], clamp: boolean): boolean { // Get the index of the data points to test against const w = this.elementSize const data = this.data let xi = Math.floor(x / w) let yi = Math.floor(y / w) result[0] = xi result[1] = yi if (clamp) { // Clamp index to edges if (xi < 0) { xi = 0 } if (yi < 0) { yi = 0 } if (xi >= data.length - 1) { xi = data.length - 1 } if (yi >= data[0].length - 1) { yi = data[0].length - 1 } } // Bail out if we are out of the terrain if (xi < 0 || yi < 0 || xi >= data.length - 1 || yi >= data[0].length - 1) { return false } return true } getTriangleAt(x: number, y: number, edgeClamp: boolean, a: Vec3, b: Vec3, c: Vec3): boolean { const idx = getHeightAt_idx this.getIndexOfPosition(x, y, idx, edgeClamp) let xi = idx[0] let yi = idx[1] const data = this.data if (edgeClamp) { xi = Math.min(data.length - 2, Math.max(0, xi)) yi = Math.min(data[0].length - 2, Math.max(0, yi)) } const elementSize = this.elementSize const lowerDist2 = (x / elementSize - xi) ** 2 + (y / elementSize - yi) ** 2 const upperDist2 = (x / elementSize - (xi + 1)) ** 2 + (y / elementSize - (yi + 1)) ** 2 const upper = lowerDist2 > upperDist2 this.getTriangle(xi, yi, upper, a, b, c) return upper } getNormalAt(x: number, y: number, edgeClamp: boolean, result: Vec3): void { const a = getNormalAt_a const b = getNormalAt_b const c = getNormalAt_c const e0 = getNormalAt_e0 const e1 = getNormalAt_e1 this.getTriangleAt(x, y, edgeClamp, a, b, c) b.vsub(a, e0) c.vsub(a, e1) e0.cross(e1, result) result.normalize() } /** * Get an AABB of a square in the heightfield * @param xi * @param yi * @param result */ getAabbAtIndex(xi: number, yi: number, { lowerBound, upperBound }: AABB): void { const data = this.data const elementSize = this.elementSize lowerBound.set(xi * elementSize, yi * elementSize, data[xi][yi]) upperBound.set((xi + 1) * elementSize, (yi + 1) * elementSize, data[xi + 1][yi + 1]) } /** * Get the height in the heightfield at a given position */ getHeightAt(x: number, y: number, edgeClamp: boolean): number { const data = this.data const a = getHeightAt_a const b = getHeightAt_b const c = getHeightAt_c const idx = getHeightAt_idx this.getIndexOfPosition(x, y, idx, edgeClamp) let xi = idx[0] let yi = idx[1] if (edgeClamp) { xi = Math.min(data.length - 2, Math.max(0, xi)) yi = Math.min(data[0].length - 2, Math.max(0, yi)) } const upper = this.getTriangleAt(x, y, edgeClamp, a, b, c) barycentricWeights(x, y, a.x, a.y, b.x, b.y, c.x, c.y, getHeightAt_weights) const w = getHeightAt_weights if (upper) { // Top triangle verts return data[xi + 1][yi + 1] * w.x + data[xi][yi + 1] * w.y + data[xi + 1][yi] * w.z } else { // Top triangle verts return data[xi][yi] * w.x + data[xi + 1][yi] * w.y + data[xi][yi + 1] * w.z } } getCacheConvexTrianglePillarKey(xi: number, yi: number, getUpperTriangle: boolean): string { return `${xi}_${yi}_${getUpperTriangle ? 1 : 0}` } getCachedConvexTrianglePillar(xi: number, yi: number, getUpperTriangle: boolean): HeightfieldPillar { return this._cachedPillars[this.getCacheConvexTrianglePillarKey(xi, yi, getUpperTriangle)] } setCachedConvexTrianglePillar( xi: number, yi: number, getUpperTriangle: boolean, convex: ConvexPolyhedron, offset: Vec3 ): void { this._cachedPillars[this.getCacheConvexTrianglePillarKey(xi, yi, getUpperTriangle)] = { convex, offset, } } clearCachedConvexTrianglePillar(xi: number, yi: number, getUpperTriangle: boolean): void { delete this._cachedPillars[this.getCacheConvexTrianglePillarKey(xi, yi, getUpperTriangle)] } /** * Get a triangle from the heightfield */ getTriangle(xi: number, yi: number, upper: boolean, a: Vec3, b: Vec3, c: Vec3): void { const data = this.data const elementSize = this.elementSize if (upper) { // Top triangle verts a.set((xi + 1) * elementSize, (yi + 1) * elementSize, data[xi + 1][yi + 1]) b.set(xi * elementSize, (yi + 1) * elementSize, data[xi][yi + 1]) c.set((xi + 1) * elementSize, yi * elementSize, data[xi + 1][yi]) } else { // Top triangle verts a.set(xi * elementSize, yi * elementSize, data[xi][yi]) b.set((xi + 1) * elementSize, yi * elementSize, data[xi + 1][yi]) c.set(xi * elementSize, (yi + 1) * elementSize, data[xi][yi + 1]) } } /** * Get a triangle in the terrain in the form of a triangular convex shape. */ getConvexTrianglePillar(xi: number, yi: number, getUpperTriangle: boolean): void { let result = this.pillarConvex let offsetResult = this.pillarOffset if (this.cacheEnabled) { const data = this.getCachedConvexTrianglePillar(xi, yi, getUpperTriangle) if (data) { this.pillarConvex = data.convex this.pillarOffset = data.offset return } result = new ConvexPolyhedron() offsetResult = new Vec3() this.pillarConvex = result this.pillarOffset = offsetResult } const data = this.data const elementSize = this.elementSize const faces = result.faces // Reuse verts if possible result.vertices.length = 6 for (let i = 0; i < 6; i++) { if (!result.vertices[i]) { result.vertices[i] = new Vec3() } } // Reuse faces if possible faces.length = 5 for (let i = 0; i < 5; i++) { if (!faces[i]) { faces[i] = [] } } const verts = result.vertices const h = (Math.min(data[xi][yi], data[xi + 1][yi], data[xi][yi + 1], data[xi + 1][yi + 1]) - this.minValue!) / 2 + this.minValue! if (!getUpperTriangle) { // Center of the triangle pillar - all polygons are given relative to this one offsetResult.set( (xi + 0.25) * elementSize, // sort of center of a triangle (yi + 0.25) * elementSize, h // vertical center ) // Top triangle verts verts[0].set(-0.25 * elementSize, -0.25 * elementSize, data[xi][yi] - h) verts[1].set(0.75 * elementSize, -0.25 * elementSize, data[xi + 1][yi] - h) verts[2].set(-0.25 * elementSize, 0.75 * elementSize, data[xi][yi + 1] - h) // bottom triangle verts verts[3].set(-0.25 * elementSize, -0.25 * elementSize, -Math.abs(h) - 1) verts[4].set(0.75 * elementSize, -0.25 * elementSize, -Math.abs(h) - 1) verts[5].set(-0.25 * elementSize, 0.75 * elementSize, -Math.abs(h) - 1) // top triangle faces[0][0] = 0 faces[0][1] = 1 faces[0][2] = 2 // bottom triangle faces[1][0] = 5 faces[1][1] = 4 faces[1][2] = 3 // -x facing quad faces[2][0] = 0 faces[2][1] = 2 faces[2][2] = 5 faces[2][3] = 3 // -y facing quad faces[3][0] = 1 faces[3][1] = 0 faces[3][2] = 3 faces[3][3] = 4 // +xy facing quad faces[4][0] = 4 faces[4][1] = 5 faces[4][2] = 2 faces[4][3] = 1 } else { // Center of the triangle pillar - all polygons are given relative to this one offsetResult.set( (xi + 0.75) * elementSize, // sort of center of a triangle (yi + 0.75) * elementSize, h // vertical center ) // Top triangle verts verts[0].set(0.25 * elementSize, 0.25 * elementSize, data[xi + 1][yi + 1] - h) verts[1].set(-0.75 * elementSize, 0.25 * elementSize, data[xi][yi + 1] - h) verts[2].set(0.25 * elementSize, -0.75 * elementSize, data[xi + 1][yi] - h) // bottom triangle verts verts[3].set(0.25 * elementSize, 0.25 * elementSize, -Math.abs(h) - 1) verts[4].set(-0.75 * elementSize, 0.25 * elementSize, -Math.abs(h) - 1) verts[5].set(0.25 * elementSize, -0.75 * elementSize, -Math.abs(h) - 1) // Top triangle faces[0][0] = 0 faces[0][1] = 1 faces[0][2] = 2 // bottom triangle faces[1][0] = 5 faces[1][1] = 4 faces[1][2] = 3 // +x facing quad faces[2][0] = 2 faces[2][1] = 5 faces[2][2] = 3 faces[2][3] = 0 // +y facing quad faces[3][0] = 3 faces[3][1] = 4 faces[3][2] = 1 faces[3][3] = 0 // -xy facing quad faces[4][0] = 1 faces[4][1] = 4 faces[4][2] = 5 faces[4][3] = 2 } result.computeNormals() result.computeEdges() result.updateBoundingSphereRadius() this.setCachedConvexTrianglePillar(xi, yi, getUpperTriangle, result, offsetResult) } calculateLocalInertia(mass: number, target = new Vec3()): Vec3 { target.set(0, 0, 0) return target } volume(): number { return ( // The terrain is infinite Number.MAX_VALUE ) } calculateWorldAABB(pos: Vec3, quat: Quaternion, min: Vec3, max: Vec3): void { /** @TODO do it properly */ min.set(-Number.MAX_VALUE, -Number.MAX_VALUE, -Number.MAX_VALUE) max.set(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE) } updateBoundingSphereRadius(): void { // Use the bounding box of the min/max values const data = this.data const s = this.elementSize this.boundingSphereRadius = new Vec3( data.length * s, data[0].length * s, Math.max(Math.abs(this.maxValue!), Math.abs(this.minValue!)) ).length() } /** * Sets the height values from an image. Currently only supported in browser. */ setHeightsFromImage(image: HTMLImageElement, scale: Vec3): void { const { x, z, y } = scale const canvas = document.createElement('canvas') canvas.width = image.width canvas.height = image.height const context = canvas.getContext('2d')! context.drawImage(image, 0, 0) const imageData = context.getImageData(0, 0, image.width, image.height) const matrix = this.data matrix.length = 0 this.elementSize = Math.abs(x) / imageData.width for (let i = 0; i < imageData.height; i++) { const row = [] for (let j = 0; j < imageData.width; j++) { const a = imageData.data[(i * imageData.height + j) * 4] const b = imageData.data[(i * imageData.height + j) * 4 + 1] const c = imageData.data[(i * imageData.height + j) * 4 + 2] const height = ((a + b + c) / 4 / 255) * z if (x < 0) { row.push(height) } else { row.unshift(height) } } if (y < 0) { matrix.unshift(row) } else { matrix.push(row) } } this.updateMaxValue() this.updateMinValue() this.update() } } const getHeightAt_idx: number[] = [] const getHeightAt_weights = new Vec3() const getHeightAt_a = new Vec3() const getHeightAt_b = new Vec3() const getHeightAt_c = new Vec3() const getNormalAt_a = new Vec3() const getNormalAt_b = new Vec3() const getNormalAt_c = new Vec3() const getNormalAt_e0 = new Vec3() const getNormalAt_e1 = new Vec3() // from https://en.wikipedia.org/wiki/Barycentric_coordinate_system function barycentricWeights( x: number, y: number, ax: number, ay: number, bx: number, by: number, cx: number, cy: number, result: Vec3 ): void { result.x = ((by - cy) * (x - cx) + (cx - bx) * (y - cy)) / ((by - cy) * (ax - cx) + (cx - bx) * (ay - cy)) result.y = ((cy - ay) * (x - cx) + (ax - cx) * (y - cy)) / ((by - cy) * (ax - cx) + (cx - bx) * (ay - cy)) result.z = 1 - result.x - result.y }