@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
606 lines (446 loc) • 15.8 kB
JavaScript
import { ClampToEdgeWrapping, DataTexture, LinearFilter, RedFormat, UnsignedByteType } from "three";
import { assert } from "../../../core/assert.js";
import { BinaryDataType } from "../../../core/binary/type/BinaryDataType.js";
import { RowFirstTable } from "../../../core/collection/table/RowFirstTable.js";
import { RowFirstTableSpec } from "../../../core/collection/table/RowFirstTableSpec.js";
import Signal from "../../../core/events/signal/Signal.js";
import Vector1 from "../../../core/geom/Vector1.js";
import Vector2 from "../../../core/geom/Vector2.js";
import Vector4 from "../../../core/geom/Vector4.js";
import { clamp } from "../../../core/math/clamp.js";
import { max2 } from "../../../core/math/max2.js";
import { min2 } from "../../../core/math/min2.js";
import { computeUnsignedDistanceField } from "../../graphics/texture/sampler/distance/computeUnsignedDistanceField.js";
import { Sampler2D } from "../../graphics/texture/sampler/Sampler2D.js";
import { writeSample2DDataToDataTexture } from "../../graphics/texture/sampler/writeSampler2DDataToDataTexture.js";
const samplePosition = [];
const corners2d = new Float64Array(8);
/**
* Measured in Uint8 channel value per second.
* @example 255 would mean complete fade happens in 1s, 25.5 would mean fade happens over 10 seconds
* @readonly
* @type {number}
*/
const FADE_SPEED = 255;
/**
* pre-fade to enable correct distance field calculation
* @readonly
* @type {number}
*/
const INITIAL_FADE_VALUE = 254;
/**
*
* @type {RowFirstTableSpec}
*/
const revealMaskTableSpec = new RowFirstTableSpec([
BinaryDataType.Int32, // Index
BinaryDataType.Float32, // Precise current value
BinaryDataType.Float32 // Fade speed
]);
const fadeRow = [];
/**
* @class
*/
export class FogOfWar {
/**
*
* @type {boolean}
*/
textureNeedsUpdate = true;
/**
*
* @type {boolean}
*/
distanceFieldNeedsUpdate = false;
/**
*
* @type {DataTexture|null}
*/
texture = null;
/**
* Resolution scale of FoW versus terrain resolution, higher number makes FoW more detailed
* @type {Vector1}
*/
scale = new Vector1(1);
/**
* Size of the fog area
* @type {Vector2}
*/
size = new Vector2(0, 0);
color = new Vector4(0.1, 0.1, 0.1, 1);
/**
* Contains indices of pixes that are currently being updated
* @type {RowFirstTable}
*/
fadeMask = new RowFirstTable(revealMaskTableSpec);
on = {
textureChanged: new Signal()
};
/**
*
* @type {Sampler2D}
*/
sampler = Sampler2D.uint8(1, 0, 0);
/**
*
* @type {Sampler2D}
*/
distanceSampler = Sampler2D.uint8(1, 0, 0);
/**
*
* @param {number} timeDelta
*/
updateFade(timeDelta) {
const samplerData = this.sampler.data;
const fadeMask = this.fadeMask;
let numRows = fadeMask.length;
let i = 0;
for (; i < numRows; i++) {
//read row
fadeMask.readRow(i, fadeRow);
//decode row
const index = fadeRow[0];
const oldValue = fadeRow[1];
const speed = fadeRow[2];
//compute new value
const value = oldValue + (speed * timeDelta);
let writeValue = value | 0;
//write new value
fadeMask.writeCellValue(i, 1, value);
if (value <= 0) {
if (speed < 0) {
//we were going down and reached the end, clear row
fadeMask.removeRows(i, 1);
i--;
numRows--;
}
writeValue = 0;
} else if (value >= 255) {
writeValue = 255;
if (speed > 0) {
//we were going down and reached the end, clear row
fadeMask.removeRows(i, 1);
i--;
numRows--;
}
}
if ((oldValue | 0) !== writeValue) {
//value has changed
samplerData[index] = writeValue;
this.textureNeedsUpdate = true;
}
}
}
/**
*
* @param {number} x
* @param {number} y
*/
revealPoint(x, y) {
const fadeMask = this.fadeMask;
const sampler = this.sampler;
const samplerData = sampler.data;
const samplerWidth = sampler.width;
const index = (y + 1) * samplerWidth + (x + 1);
if (samplerData[index] === 255) {
samplerData[index] = INITIAL_FADE_VALUE;
//add record to fade mask
fadeRow[0] = index;
fadeRow[1] = INITIAL_FADE_VALUE;
fadeRow[2] = -FADE_SPEED;
fadeMask.addRow(fadeRow);
this.distanceFieldNeedsUpdate = true;
}
}
clear() {
this.fadeMask.clear();
this.sampler.data.fill(255);
this.distanceFieldNeedsUpdate = true;
this.textureNeedsUpdate = true;
}
revealAll() {
this.fadeMask.clear();
this.sampler.data.fill(0);
this.distanceFieldNeedsUpdate = true;
this.textureNeedsUpdate = true;
}
concealAll() {
this.clear();
}
/**
*
* @param {number} x
* @param {number} y
* @param {number} radius
*/
reveal(x, y, radius) {
assert.isNumber(x, 'x');
assert.isNumber(y, 'y');
assert.isNumber(radius, 'radius');
const sampler = this.sampler;
const samplerWidth = sampler.width;
const lastPixelY = sampler.height - 1;
const lastPixelX = samplerWidth - 1;
let changeFlag = false;
const samplerData = sampler.data;
const fadeMask = this.fadeMask;
sampler.traverseCircle(x + 1, y + 1, radius, function (x, y, sampler) {
if (x <= 0 || x >= lastPixelX || y <= 0 || y >= lastPixelY) {
//edges are reserved to preserve cover outside of the working area
return;
}
const index = y * samplerWidth + x;
if (samplerData[index] === 255) {
samplerData[index] = INITIAL_FADE_VALUE;
//add record to fade mask
fadeRow[0] = index;
fadeRow[1] = INITIAL_FADE_VALUE;
fadeRow[2] = -FADE_SPEED;
fadeMask.addRow(fadeRow);
changeFlag = true;
}
});
if (changeFlag) {
this.rebuildDistanceField();
}
}
/**
*
* @param {number} x
* @param {number} y
* @param {number} radius
*/
conceal(x, y, radius) {
assert.isNumber(x, 'x');
assert.isNumber(y, 'y');
assert.isNumber(radius, 'radius');
const sampler = this.sampler;
const samplerWidth = sampler.width;
const lastPixelY = sampler.height - 1;
const lastPixelX = samplerWidth - 1;
let changeFlag = false;
const samplerData = sampler.data;
const fadeMask = this.fadeMask;
sampler.traverseCircle(x + 1, y + 1, radius, function (x, y, sampler) {
if (x <= 0 || x >= lastPixelX || y <= 0 || y >= lastPixelY) {
//edges are reserved to preserve cover outside of the working area
return;
}
const index = y * samplerWidth + x;
const value = samplerData[index];
if (value !== 255) {
//add record to fade mask
fadeRow[0] = index;
fadeRow[1] = value;
fadeRow[2] = FADE_SPEED;
fadeMask.addRow(fadeRow);
changeFlag = true;
}
});
if (changeFlag) {
this.rebuildDistanceField();
}
}
dispose() {
if (this.texture !== null) {
this.texture.dispose();
}
}
updateTexture() {
const s = this.sampler;
/**
*
* @type {Uint8Array}
*/
const data = s.data;
if (this.texture === null) {
const texture = new DataTexture(data, s.width, s.height, RedFormat, UnsignedByteType);
texture.flipY = false;
texture.wrapS = ClampToEdgeWrapping;
texture.wrapT = ClampToEdgeWrapping;
texture.generateMipmaps = false;
texture.magFilter = LinearFilter;
texture.minFilter = LinearFilter;
texture.needsUpdate = true;
this.texture = texture;
} else {
writeSample2DDataToDataTexture(s, this.texture);
}
this.on.textureChanged.send0();
//clear flag
this.textureNeedsUpdate = false;
}
/**
*
* @param {number} timeDelta
*/
update(timeDelta) {
this.updateFade(timeDelta);
if (this.textureNeedsUpdate) {
this.updateTexture();
}
if (this.distanceFieldNeedsUpdate) {
this.rebuildDistanceField();
}
}
/**
* Compute bounding box of revealed area in grid space, where coordinate system maps to discrete texels of the fog
* @param {AABB2} result
*/
computeRevealedGridBoundingRectangle(result) {
const sampler = this.sampler;
const data = sampler.data;
const l = data.length;
const width = sampler.width;
let i = 0;
let x0 = width - 1, y0 = sampler.height - 1, x1 = 0, y1 = 0;
let x, y;
for (; i < l; i++) {
const value = data[i];
if (value !== 255) {
//revealed
x = i % width;
y = Math.floor(i / width);
x0 = min2(x, x0);
y0 = min2(y, y0);
x1 = max2(x, x1);
y1 = max2(y, y1);
}
}
result.set(x0, y0, x1, y1);
}
/**
*
* @param {number} x
* @param {number} y
* @param {number[]} result
*/
worldToSamplePosition(x, y, result) {
const scale = this.scale.getValue();
const size = this.size;
const sizeX = size.x;
const sizeY = size.y;
const scaleX = (sizeX - 1) / (scale * sizeX);
const scaleY = (sizeY - 1) / (scale * sizeY);
result[0] = x * scaleX + 1;
result[1] = y * scaleY + 1;
}
/**
*
* @param {AABB3} aabb
* @param {Vector3} cameraFocalPoint
* @param {number} clearance distance to nearest non-occluding cell
*/
computeAABBVisibility(aabb, cameraFocalPoint, clearance) {
assert.isNumber(clearance, 'clearance');
let sx0 = this.size.x - 1;
let sy0 = this.size.y - 1;
let sx1 = 0;
let sy1 = 0;
let i;
//cast ray from object's position along inverted view plane normal
corners2d[0] = aabb.x0;
corners2d[1] = aabb.z0;
corners2d[2] = aabb.x0;
corners2d[3] = aabb.z1;
corners2d[4] = aabb.x1;
corners2d[5] = aabb.z0;
corners2d[6] = aabb.x1;
corners2d[7] = aabb.z1;
const distanceSampler = this.distanceSampler;
//check corners, this is fast compared to checking each cell of fog overlapping bounding box
for (i = 0; i < 8; i += 2) {
const x = corners2d[i];
const z = corners2d[i + 1];
//scale down position to obtain sample position
this.worldToSamplePosition(x, z, samplePosition);
const sampleX = samplePosition[0];
const sampleY = samplePosition[1];
const sX = clamp(sampleX, 0, this.size.x - 1);
const sY = clamp(sampleY, 0, this.size.y - 1);
const distance = distanceSampler.readChannel(sX | 0, sY | 0, 0);
//Check if cell is visible
if (distance <= clearance) {
//if fog is not 100% opaque - return as visible
return true;
}
sx0 = min2(sx0, sX);
sy0 = min2(sy0, sY);
sx1 = max2(sx1, sX);
sy1 = max2(sy1, sY);
}
sx0 = Math.floor(sx0);
sy0 = Math.floor(sy0);
sx1 = Math.ceil(sx1);
sy1 = Math.ceil(sy1);
const shExtX = Math.ceil((sx1 - sx0) / 2);
const shExtY = Math.ceil((sy1 - sy0) / 2);
const sMidX = sx0 + shExtX;
const sMidY = sy0 + shExtY;
const centerOcclusionDistance = distanceSampler.readChannel(sMidX | 0, sMidY | 0, 0);
const maxDistanceFromCenter = max2(shExtX, shExtY);
const distanceToNearestClearance = centerOcclusionDistance - maxDistanceFromCenter;
if (distanceToNearestClearance > clearance) {
//distance from center of the rectangle to nearest non-occluded region is too great, treat as fully occluded
return false;
} else {
return true;
}
}
/**
* Returns fog clearance from a set of world coordinates
* @param {number} x world X coordinate
* @param {number} y world Y coordinate
* @returns {number} clearance value
*/
getWorldClearance(x, y) {
this.worldToSamplePosition(x, y, samplePosition);
const sampleX = samplePosition[0];
const sampleY = samplePosition[1];
return this.distanceSampler.readChannel(sampleX | 0, sampleY | 0, 0);
}
rebuildDistanceField() {
// console.time('computeSignedDistanceField');
computeUnsignedDistanceField(this.sampler, this.distanceSampler, 255);
// console.timeEnd('computeSignedDistanceField');
this.distanceFieldNeedsUpdate = false;
}
rebuildDistanceSampler() {
const array = new Uint8Array(this.sampler.width * this.sampler.height);
array.fill(0);
this.distanceSampler.data = array;
this.distanceSampler.width = this.sampler.width;
this.distanceSampler.height = this.sampler.height;
this.rebuildDistanceField();
}
rebuildSampler() {
//resize the sampler
const samplerHeight = this.size.x + 2;
const samplerWidth = this.size.y + 2;
const uint8Array = new Uint8Array(samplerWidth * samplerHeight);
uint8Array.fill(255);
const sampler = new Sampler2D(uint8Array, 1, samplerWidth, samplerHeight);
sampler.copy(this.sampler, 0, 0, 0, 0, this.sampler.width, this.sampler.height);
//rewrite sampler
this.sampler.data = sampler.data;
this.sampler.width = sampler.width;
this.sampler.height = sampler.height;
//rebuild distance sampler
this.rebuildDistanceSampler();
//Mark texture for update
this.textureNeedsUpdate = true;
}
/**
*
* @param {number} w
* @param {number} h
* @param scale
*/
resize(w, h, scale) {
this.size.set(w, h);
this.scale.set(scale);
//TODO update fade mask as necessary
this.rebuildSampler();
}
}
FogOfWar.typeName = 'FogOfWar';