UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

753 lines (580 loc) • 19.8 kB
import { mat4, vec3 } from "gl-matrix"; import { MeshPhongMaterial } from 'three'; import { assert } from "../../../../core/assert.js"; import { BVH } from "../../../../core/bvh2/bvh3/BVH.js"; import { bvh_query_leaves_ray } from "../../../../core/bvh2/bvh3/query/bvh_query_leaves_ray.js"; import { isArrayEqualStrict } from "../../../../core/collection/array/isArrayEqualStrict.js"; import { Color } from "../../../../core/color/Color.js"; import Signal from '../../../../core/events/signal/Signal.js'; import { aabb2_overlap_exists } from "../../../../core/geom/2d/aabb/aabb2_overlap_exists.js"; import { SurfacePoint3 } from "../../../../core/geom/3d/SurfacePoint3.js"; import Vector2 from '../../../../core/geom/Vector2.js'; import { NumericInterval } from "../../../../core/math/interval/NumericInterval.js"; import { randomFloatBetween } from "../../../../core/math/random/randomFloatBetween.js"; import ObservedInteger from "../../../../core/model/ObservedInteger.js"; import ObservedValue from '../../../../core/model/ObservedValue.js'; import CheckersTexture from '../../../graphics/texture/CheckersTexture.js'; import TerrainTile from './TerrainTile.js'; /** * * @type {number[]} */ const scratch_array = []; const scratch_contact = new SurfacePoint3(); class TerrainTileManager { /** * * @type {TerrainTile[]} */ tiles = []; on = { tileBuilt: new Signal(), tileDestroyed: new Signal() }; /** * * @type {Vector2} */ tileSize = new Vector2(10, 10); /** * * @type {Vector2} */ totalSize = new Vector2(1, 1); /** * Number of subdivisions per single grid cell * @type {ObservedInteger} */ resolution = new ObservedInteger(4); /** * 2D Scale of the terrain * @type {Vector2} */ scale = new Vector2(1, 1); /** * * @type {Float32Array} * @private */ __transform = new Float32Array(16); /** * @readonly * @type {BVH} */ bvh = new BVH(); /** * * @type {NumericInterval} */ heightRange = new NumericInterval(0, 0); /** * Debug parameter, makes all tiles have random colored material for easy visual distinction * @type {boolean} */ debugTileMaterialRandom = false; /** * * @param {Vector2} [tileSize] * @param {Material} [material] * @param {WorkerProxy} buildWorker */ constructor( { material, buildWorker } ) { if (material === undefined) { const defaultMaterialTexture = CheckersTexture.create(this.totalSize.clone()._sub(1, 1).multiplyScalar(0.5)); material = new MeshPhongMaterial({ map: defaultMaterialTexture }); } this.material = new ObservedValue(material); /** * * @type {WorkerProxy} */ this.buildWorker = buildWorker; this.material.onChanged.add(() => { this.traverse(this.assignTileMaterial, this); }); } set transform(m4) { if (isArrayEqualStrict(m4, this.__transform)) { // no change return; } mat4.copy(this.__transform, m4); this.traverse(t => { t.transform = m4; }); } get transform() { return this.__transform; } /** * * @param {number} min_height * @param {number} max_height */ setHeightRange(min_height, max_height) { assert.isNumber(min_height, 'min_height'); assert.notNaN(min_height, 'min_height'); assert.isNumber(max_height, 'max_height'); assert.notNaN(max_height, 'max_height'); this.heightRange.set(min_height, max_height); const tiles = this.tiles; const n = tiles.length; for (let i = 0; i < n; i++) { const terrainTile = tiles[i]; if ( !terrainTile.isBuilt && !terrainTile.isBuildInProgress ) { const bb = terrainTile.external_bvh; bb.bounds[1] = min_height; bb.bounds[4] = max_height; bb.write_bounds(); } } } initialize() { this.destroyTiles(); this.initializeTiles(); } /** * * @param {TerrainTile} tile */ assignTileMaterial(tile) { let material = this.material.getValue(); if (this.debugTileMaterialRandom) { const color = new Color(); color.setHSV(Math.random(), randomFloatBetween(Math.random, 0.4, 1), 1); material = new MeshPhongMaterial({ color: color.toUint() }); } tile.material = material; if (tile.mesh !== null) { tile.mesh.material = material; } } /** * * @param {function(tile:TerrainTile)} callback * @param {*} [thisArg] */ traverse(callback, thisArg) { const tiles = this.tiles; let tile; let i = 0; const il = tiles.length; for (; i < il; i++) { tile = tiles[i]; callback.call(thisArg, tile); } } destroyTiles() { //destroy all existing tiles const tiles = this.tiles; const tile_count = tiles.length; console.warn(`#destroyTiles tile_count=${tile_count}`); for (let i = 0; i < tile_count; i++) { const tile = tiles[i]; tile.external_bvh.unlink(); tile.dispose(); } tiles.splice(0, tile_count); //clear out BVH this.bvh.release_all(); } /** * Rebuild all tiles */ rebuild() { this.destroyTiles(); this.initializeTiles(); } /** * Rebuild tiles that overlap rectangular region of the overall terrain defined by normalized coordinates (UV space) * @param {number} u0 * @param {number} v0 * @param {number} u1 * @param {number} v1 */ rebuildTilesByUV( u0, v0, u1, v1 ) { const size = this.totalSize; const tx0 = u0 * size.x; const tx1 = u1 * size.x; const ty0 = v0 * size.y; const ty1 = v1 * size.y; const dirty_tiles = this.getRawTilesOverlappingRectangle(tx0 - 1, ty0 - 1, tx1 + 1, ty1 + 1); const dirty_count = dirty_tiles.length; for (let i = 0; i < dirty_count; i++) { const tile = dirty_tiles[i]; tile.isBuilt = false; } } /** * * @param {number} x0 * @param {number} y0 * @param {number} x1 * @param {number} y1 * @returns {TerrainTile[]} */ getRawTilesOverlappingRectangle(x0, y0, x1, y1) { /** * * @type {TerrainTile[]} */ const result = []; const terrainTiles = this.tiles; const n = terrainTiles.length; for (let i = 0; i < n; i++) { const tile = terrainTiles[i]; const tx0 = tile.position.x; const ty0 = tile.position.y; const tx1 = tx0 + tile.size.x; const ty1 = ty0 + tile.size.y; if ( aabb2_overlap_exists( x0, y0, x1, y1, tx0, ty0, tx1, ty1 ) ) { result.push(tile); } } return result; } initializeTiles() { const total_size = this.totalSize; const gridSize = total_size.clone(); const time_size = this.tileSize; gridSize.divide(time_size); gridSize.ceil(); const tiles = this.tiles; if (tiles.length > 0) { throw new Error(`There are already ${tiles.length} initialized tiles, those must be destroyed before initialization can happen`); } //populate tiles const tile_resolution_y = gridSize.y; const tile_resolution_x = gridSize.x; for (let y = 0; y < tile_resolution_y; y++) { const tY = y < tile_resolution_y - 1 ? time_size.y : (total_size.y - time_size.y * y); for (let x = 0; x < tile_resolution_x; x++) { const tX = x < tile_resolution_x - 1 ? time_size.x : (total_size.x - time_size.x * x); const tile = new TerrainTile(); const index = y * tile_resolution_x + x; tiles[index] = tile; this.assignTileMaterial(tile); tile.gridPosition.set(x, y); tile.size.set(tX, tY); tile.position.set(time_size.x * x, time_size.y * y); tile.scale.copy(this.scale); tile.resolution.copy(this.resolution); tile.setInitialHeightBounds(this.heightRange.min, this.heightRange.max); tile.computeBoundingBox(); tile.external_bvh.link(this.bvh, index); } } } /** * * @param {number} x Tile X coordinate * @param {number} y Tile Y coordinate * @returns {number} */ computeTileIndex(x, y) { assert.isNonNegativeInteger(x, 'x'); assert.isNonNegativeInteger(y, 'y'); const w = Math.ceil(this.totalSize.x / this.tileSize.x); assert.ok(x < w, `x(=${x}) must be less than than width(=${w})`); assert.ok(y < Math.ceil(this.totalSize.y / this.tileSize.y), `y(=${y}) must be less than than height(=${Math.ceil(this.totalSize.y / this.tileSize.y)})`); return y * w + x; } /** * * @param {number} x * @param {number} y * @returns {TerrainTile|undefined} */ getRaw(x, y) { if ( x < 0 || x >= Math.ceil(this.totalSize.x / this.tileSize.x) || y < 0 ) { return undefined; } const tileIndex = this.computeTileIndex(x, y); assert.ok(tileIndex >= 0, `tileIndex(=${tileIndex}) must be >= 0`); assert.ok(tileIndex <= this.tiles.length, `tileIndex(=${tileIndex}) must be <= tileCount(=${this.tiles.length})`); return this.tiles[tileIndex]; } /** * * @param {number} x Grid X coordinate * @param {number} y Grid Y coordinate * @returns {TerrainTile} */ getRawTileByPosition(x, y) { assert.isNumber(x, 'x'); assert.notNaN(x, 'x'); assert.isNumber(y, 'y'); assert.notNaN(y, 'y'); const tileX = Math.floor(x / this.tileSize.x); const tileY = Math.floor(y / this.tileSize.y); return this.getRaw(tileX, tileY); } /** * Given world coordinates in top-down plane, where X runs along X axis and Y runs along Z axis, returns terrain tile that overlaps that 2d region * @param {number} x * @param {number} y * @return {TerrainTile|undefined} */ getTileByWorldPosition2D(x, y) { const v3 = vec3.fromValues(x, 0, y); const world_inverse = mat4.create(); mat4.invert(world_inverse, this.transform); vec3.transformMat4(v3, v3, world_inverse); return this.getRawTileByPosition(v3[0], v3[1]); } /** * Builds and returns the tile from world coordinates * @param {number} x * @param {number} y * @returns {Promise<TerrainTile>} */ obtainTileAtWorldPosition2D(x, y) { assert.isNumber(x, 'x') assert.notNaN(x, 'x') assert.isNumber(y, 'y') assert.notNaN(y, 'y') const tile = this.getTileByWorldPosition2D(x, y); return this.obtain(tile.gridPosition.x, tile.gridPosition.y); } /** * * @param {number} x coordinate * @param {number} y coordinate * @returns {Promise<TerrainTile>} * @throws if no tile exists at given coordinates */ obtain(x, y) { const tile = this.getRaw(x, y); if (tile === undefined) { throw new Error(`No tile found at x=${x},y=${y}`); } tile.referenceCount++; if (tile.isBuilt) { return Promise.resolve(tile); } else { if (!tile.isBuildInProgress) { return new Promise((resolve, reject) => this.build(x, y, resolve, reject)); } else { return new Promise((resolve, reject) => { tile.onBuilt.addOne(resolve); tile.onDestroyed.addOne(reject); }); } } } /** * * @param {TerrainTile} tile */ release(tile) { if (tile.referenceCount <= 0) { console.warn('Tile already has no references'); } tile.referenceCount--; if (tile.referenceCount <= 0) { //potential garbage tile.dispose(); } } dispose() { const tiles = this.tiles; const n = tiles.length; for (let i = 0; i < n; i++) { const t = tiles[i]; t.dispose(); } } /** * Fix normals along the seams of the tile * * @param {number} x * @param {number} y * @param {TerrainTile} tile */ stitchTile(x, y, tile) { const gridSize = this.totalSize.clone(); gridSize.divide(this.tileSize); gridSize.floor(); const self = this; //normal stitching let top, bottom, left, right, topLeft, topRight, bottomLeft, bottomRight; if (y > 0) { top = self.getRaw(x, y - 1); if (x > 0) { topLeft = self.getRaw(x - 1, y - 1); } if (x < gridSize.x - 1) { topRight = self.getRaw(x + 1, y - 1); } } if (y < gridSize.y - 1) { bottom = self.getRaw(x, y + 1); if (x > 0) { bottomLeft = self.getRaw(x - 1, y + 1); } if (x < gridSize.x - 1) { bottomRight = self.getRaw(x + 1, y + 1); } } if (x > 0) { left = self.getRaw(x - 1, y); } if (x < gridSize.x - 1) { right = self.getRaw(x + 1, y); } tile.stitchNormals(top, bottom, left, right, topLeft, topRight, bottomLeft, bottomRight); } /** * * @param {SurfacePoint3} result * @param {number} originX * @param {number} originY * @param {number} originZ * @param {number} directionX * @param {number} directionY * @param {number} directionZ * @returns {boolean} */ raycastFirstSync( result, originX, originY, originZ, directionX, directionY, directionZ ) { const hit_count = bvh_query_leaves_ray( this.bvh, this.bvh.root, scratch_array, 0, originX, originY, originZ, directionX, directionY, directionZ ); let closest_hit_distance = Number.POSITIVE_INFINITY; let hit_found = false; for (let i = 0; i < hit_count; i++) { const node_id = scratch_array[i]; const tile_index = this.bvh.node_get_user_data(node_id); const tile = this.tiles[tile_index]; if (!tile.isBuilt) { continue; } const tile_hit_found = tile.raycastFirstSync( scratch_contact, originX, originY, originZ, directionX, directionY, directionZ ); if (!tile_hit_found) { continue; } hit_found = true; const distance_sqr = scratch_contact.position._distanceSqrTo(originX, originY, originZ); if (distance_sqr < closest_hit_distance) { closest_hit_distance = distance_sqr; result.copy(scratch_contact); } } return hit_found; } /** * TODO untested * @param {SurfacePoint3} contact * @param {number} x * @param {number} y * @return {boolean} */ raycastVerticalFirstSync(contact, x, y) { // console.log('+ raycastVerticalFirstSync'); const r = this.raycastFirstSync(contact, x, -10000, y, 0, 1, 0); // console.log('- raycastVerticalFirstSync'); return r; } /** * * @param {number} x * @param {number} y * @param {function(tile:TerrainTile)} resolve * @param {function(reason:*)} reject */ build(x, y, resolve, reject) { assert.isFunction(resolve, 'resolve'); assert.isFunction(reject, 'reject'); const tile_index = this.computeTileIndex(x, y); const tile = this.tiles[tile_index]; if (tile.isBuildInProgress) { throw new Error('Tile is already in process of being built'); } const start_version = tile.version; tile.isBuilt = false; tile.isBuildInProgress = true; const tile_resolution = tile.resolution.getValue(); this.buildWorker.buildTile( tile.position.toJSON(), tile.size.toJSON(), tile.scale.toJSON(), this.totalSize.toJSON(), tile_resolution ).then((tileData) => { //check that the tile under index is still the same tile if (this.tiles[tile_index] !== tile) { //the original tile was destroyed reject('Original tile was destroyed during build process'); return; } if (tile.version !== start_version) { reject(`Tile version changed, likely due to concurrent build request. Expected version ${start_version}, actual version ${tile.version}`); return; } if (!tile.isBuildInProgress) { if (tile.isBuilt) { // tile already built resolve(tile); return; } else { reject('Build request has been cancelled'); return; } } // const processName = 'building tile x = ' + x + ", y = " + y; // console.time(processName); tile.build(tileData); assert.equal(tile.resolution.getValue(), tile_resolution, 'tile resolution has changed'); this.stitchTile(x, y, tile); //refit the bvh tile.external_bvh.write_bounds(); tile.isBuilt = true; tile.isBuildInProgress = false; //invoke callbacks tile.onBuilt.send1(tile); // console.timeEnd(processName); this.on.tileBuilt.send1(tile); resolve(tile); }, (reason) => { tile.isBuilt = false; tile.isBuildInProgress = false; reject(reason); }); } } export default TerrainTileManager;