@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
753 lines (580 loc) • 19.8 kB
JavaScript
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;