@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
377 lines (299 loc) • 14.4 kB
JavaScript
import { Matrix4 as ThreeMatrix4 } from "three";
import { assert } from "../../../../core/assert.js";
import { AABB3 } from "../../../../core/geom/3d/aabb/AABB3.js";
import { ResourceAccessKind } from "../../../../core/model/ResourceAccessKind.js";
import { ResourceAccessSpecification } from "../../../../core/model/ResourceAccessSpecification.js";
import { AbstractContextSystem } from "../../../ecs/system/AbstractContextSystem.js";
import { Transform } from '../../../ecs/transform/Transform.js';
import { ForwardPlusRenderingPlugin } from "../../render/forward_plus/plugin/ForwardPlusRenderingPlugin.js";
import { Camera } from '../camera/Camera.js';
import { Light } from './Light.js';
import { LightContext } from "./LightContext.js";
import { LightType } from "./LightType.js";
import { compute_view_frustum_aabb_in_space } from "./shadow/compute_view_frustum_aabb_in_space.js";
import { extend_shadow_camera_near_for_casters } from "./shadow/extend_shadow_camera_near_for_casters.js";
import { setShadowCameraDimensionsDiscrete } from "./shadow/setShadowCameraDimensionsDiscrete.js";
import { ShadowManager } from "./shadow/ShadowManager.js";
import { ThreeLightCache } from "./three/ThreeLightCache.js";
class LightSystem extends AbstractContextSystem {
/**
*
* @param {Engine} engine
* @param settings
* @constructor
*/
constructor(engine, settings = {}) {
super(LightContext);
/**
*
* @type {GraphicsEngine}
*/
const graphics = engine.graphics;
assert.defined(graphics, 'graphics');
assert.defined(settings, 'settings');
this.dependencies = [Light, Transform];
this.components_used = [
ResourceAccessSpecification.from(Light, ResourceAccessKind.Write)
];
this.engine = engine;
this.scene = graphics.scene;
this.settings = settings;
/**
*
* @type {GraphicsEngine}
* @private
*/
this.__graphics = graphics;
/**
*
* @type {Camera}
* @private
*/
this.__camera_object = null;
this.bindings = [];
/**
*
* @type {ShadowManager}
* @private
*/
this.__shadows = new ShadowManager();
this.__shadows.graphics = graphics;
/**
*
* @type {boolean}
* @private
*/
this.__use_forward_plus = true;
/**
*
* @type {Reference<ForwardPlusRenderingPlugin>|null}
* @private
*/
this.__plugin = null;
/**
*
* @type {ThreeLightCache}
* @private
*/
this.__three_light_cache = new ThreeLightCache();
// connect light cache
this.__three_light_cache.attach(this.scene);
if (settings.preAllocateLightsDirection !== undefined) {
this.__three_light_cache.reserve(LightType.DIRECTION, settings.preAllocateLightsDirection);
}
if (settings.preAllocateLightsSpot !== undefined) {
this.__three_light_cache.reserve(LightType.SPOT, settings.preAllocateLightsSpot);
}
if (settings.preAllocateLightsPoint !== undefined) {
this.__three_light_cache.reserve(LightType.POINT, settings.preAllocateLightsPoint);
}
if (settings.preAllocateLightsAmbient !== undefined) {
this.__three_light_cache.reserve(LightType.AMBIENT, settings.preAllocateLightsAmbient);
}
}
get componentClass() {
return Light;
}
/**
*
* @param {string} name
* @param {string|number|boolean} value
*/
setConfiguration(name, value) {
this.settings[name] = value;
const em = this.entityManager;
if (em === null) {
return;
}
const dataset = em.dataset;
if (dataset === null) {
return;
}
dataset.traverseComponents(Light, this.applySettingsOne, this);
}
/**
*
* @param {Light} component
* @param {number} entity
*/
applySettingsOne(component, entity) {
const ctx = this.__getEntityContext(entity);
ctx.applySettings();
}
async startup(entityManager) {
if (this.__use_forward_plus) {
this.__plugin = await this.engine.plugins.acquire(ForwardPlusRenderingPlugin);
}
this.__graphics.on.visibilityConstructionEnded.add(this.__updateShadowCameraForActiveCamera, this);
}
async shutdown(entityManager) {
if (this.__plugin !== null) {
this.__plugin.release();
this.__plugin = null;
}
this.__graphics.on.visibilityConstructionEnded.remove(this.__updateShadowCameraForActiveCamera, this);
}
__updateShadowCameraForActiveCamera() {
const em = this.entityManager;
const dataset = em.dataset;
if (dataset !== null) {
dataset.traverseComponents(Camera, this.__updateShadowCameraForCamera, this);
}
}
/**
*
* @param {Light} light
* @param {number} lightEntity
* @private
*/
__visit_entity_to_update_shadow(light, lightEntity) {
/**
*
1) Calculate the 8 corners of the view frustum in world space. This can be done by using the inverse view-projection matrix to transform the 8 corners of the NDC cube (which in OpenGL is [‒1, 1] along each axis).
2) Transform the frustum corners to a space aligned with the shadow map axes. This would commonly be the directional light object's local space.
(In fact, steps 1 and 2 can be done in one step by combining the inverse view-projection matrix of the camera with the inverse world matrix of the light.)
3) Calculate the bounding box of the transformed frustum corners. This will be the view frustum for the shadow map.
4) Pass the bounding box's extents to glOrtho or similar to set up the orthographic projection matrix for the shadow map.
There are a couple caveats with this basic approach. First, the Z bounds for the shadow map will be tightly fit around the view frustum, which means that objects outside the view frustum, but between the view frustum and the light, may fall outside the shadow frustum. This could lead to missing shadows. To fix this, depth clamping can be enabled so that objects in front of the shadow frustum will be rendered with clamped Z instead of clipped. Alternatively, the Z-near of the shadow frustum can be pushed out to ensure any possible shadowers are included.
The bigger issue is that this produces a shadow frustum that continuously changes size and position as the camera moves around. This leads to shadows "swimming", which is a very distracting artifact. In order to fix this, it's common to do the following additional two steps:
1) Fix the overall size of the frustum based on the longest diagonal of the camera frustum. This ensures that the camera frustum can fit into the shadow frustum in any orientation. Don't allow the shadow frustum to change size as the camera rotates.
2) Discretize the position of the frustum, based on the size of texels in the shadow map. In other words, if the shadow map is 1024×1024, then you only allow the frustum to move around in discrete steps of 1/1024th of the frustum size. (You also need to increase the size of the frustum by a factor of 1024/1023, to give room for the shadow frustum and view frustum to slip against each other.)
If you do these, the shadow will remain rock solid in world space as the camera moves around. (It won't remain solid if the camera's FOV, near or far planes are changed, though.)
As a bonus, if you do all the above, you're well on your way to implementing cascaded shadow maps, which are "just" a set of shadow maps calculated from the view frustum as above, but using different view frustum near and far plane values to place each shadow map.
*/
const l = light.__threeObject;
if (l !== null && light.type.getValue() !== LightType.AMBIENT) {
//only non-ambient lights can cast shadow
if (light.castShadow.getValue()) {
l.castShadow = true;
three_update_shadow_camera_extents(
this.__camera_object,
l,
this.settings.shadowDistance ?? DEFAULT_SHADOW_DISTANCE,
this.__graphics.layers
);
} else {
l.castShadow = false;
}
}
}
/**
*
* @param {Camera} camera
* @param {number} cameraEntity
* @returns {boolean}
* @private
*/
__updateShadowCameraForCamera(camera, cameraEntity) {
const em = this.entityManager;
const dataset = em.dataset;
if (!camera.active.getValue()) {
return true;
}
/**
*
* @type {Camera}
* @private
*/
this.__camera_object = camera.object;
if (this.__camera_object === null) {
return false;
}
dataset.traverseComponents(Light, this.__visit_entity_to_update_shadow, this);
//stop further traversal
return false;
}
}
const scratch_aabb3 = new AABB3();
const scratch_view_projection = new ThreeMatrix4();
const DEFAULT_SHADOW_DISTANCE = Infinity;
/**
* Fit the shadow camera's orthographic frustum to the main view frustum by
* projecting the view frustum into the shadow camera's local space and
* bounding it. The view frustum is treated as a hexahedron (6 inward-facing
* planes); its 8 corners are solved via the engine's frustum utilities, then
* transformed into shadow-camera-local space where they define the AABB used
* to drive the orthographic projection (left/right/top/bottom from x/y,
* near/far from z).
*
* With a single shadow cascade, fitting the shadow to the full view frustum
* gives terrible resolution at typical camera far distances (1024 texels split
* across hundreds of world units). `shadow_distance` caps the depth of the view
* frustum we fit against, trading visible-shadow distance for sharpness.
*
* The receiver-only fit misses any occluder above the view frustum: a brick
* hanging in the air just above the camera's view casts no visible shadow on
* the ground below because it's clipped out of the shadow map. When `layers`
* is provided we walk every render layer's BVH for AABBs in the column above
* the receivers and pull `near` back to the closest caster found.
*
* three.js leaves shadow.camera positioned wherever it last was — the renderer
* doesn't call updateMatrices() until render time. updateMatrices is forced
* here so matrixWorldInverse reflects the light's current pose; ShadowMapRenderer
* will call it again later but the second call is idempotent.
*
* @param {THREE.Camera} camera main view camera
* @param {THREE.Light} light directional light owning the shadow camera
* @param {number} [shadow_distance] max world-space view depth to cover with
* shadow; defaults to {@link DEFAULT_SHADOW_DISTANCE}. Use Infinity to fit
* the entire view frustum (sharp shadows close up become unaffordable).
* @param {RenderLayerManager} [layers] render layers, used to find shadow
* casters outside the view frustum. Omit to use receiver-only fit.
*/
export function three_update_shadow_camera_extents(camera, light, shadow_distance = DEFAULT_SHADOW_DISTANCE, layers = null) {
assert.isNumber(shadow_distance, 'shadow_distance');
assert.greaterThan(shadow_distance, 0, 'shadow_distance');
const shadow = light.shadow;
if (shadow === undefined) {
console.error(`Light does not have a shadow`, light);
return;
}
const shadow_camera = shadow.camera;
// refresh shadow camera world matrix from the light's pose before we read
// matrixWorldInverse — without this the matrix is from whatever the camera
// was last set to (initially the origin)
shadow.updateMatrices(light, 0);
// Truncate the main camera's far plane to the shadow distance before
// extracting the view-projection matrix. We modify camera.far in place
// and restore it so callers downstream see no change.
const original_far = camera.far;
const effective_far = Math.min(original_far, shadow_distance);
const needs_truncation = effective_far < original_far;
if (needs_truncation) {
camera.far = effective_far;
camera.updateProjectionMatrix();
}
scratch_view_projection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
if (needs_truncation) {
camera.far = original_far;
camera.updateProjectionMatrix();
}
compute_view_frustum_aabb_in_space(
scratch_aabb3,
scratch_view_projection.elements,
shadow_camera.matrixWorldInverse.elements
);
setShadowCameraDimensionsDiscrete(
shadow.mapSize,
shadow_camera,
scratch_aabb3.x0,
scratch_aabb3.y0,
scratch_aabb3.z0,
scratch_aabb3.x1,
scratch_aabb3.y1,
scratch_aabb3.z1
);
// shadow camera looks down its local -Z; orthographic near/far are
// positive distances along that view direction, so a corner at light-space
// z = -k sits at distance k in front of the camera
shadow_camera.near = -scratch_aabb3.z1;
shadow_camera.far = -scratch_aabb3.z0;
// Pull near back to include occluders above the view frustum so they cast
// visible shadows. Far stays at the receiver fit — occluders behind
// receivers can't shadow into the view.
extend_shadow_camera_near_for_casters(shadow_camera, layers);
shadow_camera.updateProjectionMatrix();
// small bias to prevent shadow acne
shadow.bias = -0.0001;
}
export default LightSystem;