@matematrolii/sketchbook
Version:
3D matematrolii playground built on three.js and cannon.js
564 lines (499 loc) • 17.5 kB
text/typescript
import * as THREE from "three";
// @ts-ignore
import * as CANNON from "cannon";
import { CameraOperator } from "../core/CameraOperator";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass";
import { FXAAShader } from "three/examples/jsm/shaders/FXAAShader";
import { Detector } from "../../lib/utils/Detector";
import { Stats } from "../../lib/utils/Stats";
import * as _ from "lodash";
import { CannonDebugRenderer } from "../../lib/cannon/CannonDebugRenderer";
import { InputManager } from "../core/InputManager";
import * as Utils from "../core/FunctionLibrary";
import { LoadingManager } from "../core/LoadingManager";
import { IWorldEntity } from "../interfaces/IWorldEntity";
import { IUpdatable } from "../interfaces/IUpdatable";
import { Character } from "../characters/Character";
import { CollisionGroups } from "../enums/CollisionGroups";
import { BoxCollider } from "../physics/colliders/BoxCollider";
import { TrimeshCollider } from "../physics/colliders/TrimeshCollider";
import { Item } from "../items/Item";
import { Bullet } from "../characters/Bullet";
import { Scenario } from "./Scenario";
import { Sky } from "./Sky";
import { IEventType } from "../interfaces/IEventType";
import { EventType } from "../enums/EventType";
import {
ModelProps,
MapDataType,
MapType,
MATEMATROLII_EVENT,
} from "../enums/WorldType";
import { IWorldOptions, IWorldParams } from "../interfaces/IWorldSettings";
export class World {
public cannonDebugRenderer: CannonDebugRenderer;
public renderer: THREE.WebGLRenderer;
public camera: THREE.PerspectiveCamera;
public composer: any;
public stats: Stats;
public graphicsWorld: THREE.Scene;
public sky: Sky;
public physicsWorld: CANNON.World;
public parallelPairs: any[];
public physicsFrameRate: number;
public physicsFrameTime: number;
public physicsMaxPrediction: number;
public clock: THREE.Clock;
public renderDelta: number;
public logicDelta: number;
public requestDelta: number;
public sinceLastFrame: number;
public justRendered: boolean;
public params: any;
public inputManager: InputManager;
public cameraOperator: CameraOperator;
public timeScaleTarget: number = 1;
public scenarios: Scenario[] = [];
public characters: Character[] = [];
public characterNames: {
[key: string]: Character;
} = {};
public items: Item[] = [];
public bullets: Bullet[] = [];
public updatables: IUpdatable[] = [];
public worldOptions: IWorldOptions = null;
public worldParams: IWorldParams = null;
private lastScenarioID: string;
private animationFrame = null;
private pixelRatio: number = null;
private fxaaPass: ShaderPass = null;
constructor(
worldOptions: IWorldOptions,
worldParams: IWorldParams,
canvas?: HTMLCanvasElement
) {
const scope = this;
// WebGL not supported
if (!Detector.webgl) {
console.log(
"This browser doesn't seem to have the required WebGL capabilities"
);
}
this.worldOptions = worldOptions;
this.worldParams = worldParams;
// Renderer
this.renderer = new THREE.WebGLRenderer({
powerPreference: "high-performance",
antialias: false,
alpha: false,
stencil: false,
canvas,
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.0;
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.sortObjects = false;
//this.renderer.physicallyCorrectLights = true;
// Canvas
if (!canvas) {
document.body.appendChild(this.renderer.domElement);
}
// Auto window resize
window.addEventListener("resize", this.onWindowResize.bind(this), false);
// Three.js scene
this.graphicsWorld = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(
80,
window.innerWidth / window.innerHeight,
0.1,
300
);
// Passes
let renderPass = new RenderPass(this.graphicsWorld, this.camera);
this.fxaaPass = new ShaderPass(FXAAShader);
// FXAA
this.pixelRatio = this.renderer.getPixelRatio();
this.fxaaPass.material["uniforms"].resolution.value.x =
1 / (window.innerWidth * this.pixelRatio);
this.fxaaPass.material["uniforms"].resolution.value.y =
1 / (window.innerHeight * this.pixelRatio);
// Composer
this.composer = new EffectComposer(this.renderer);
this.composer.addPass(renderPass);
this.composer.addPass(this.fxaaPass);
// Physics
this.physicsWorld = new CANNON.World();
this.physicsWorld.gravity.set(0, -9.81, 0);
this.physicsWorld.broadphase = new CANNON.SAPBroadphase(this.physicsWorld);
this.physicsWorld.solver.iterations = 10;
this.physicsWorld.allowSleep = true;
this.parallelPairs = [];
this.physicsFrameRate = 60;
this.physicsFrameTime = 1 / this.physicsFrameRate;
this.physicsMaxPrediction = this.physicsFrameRate;
// RenderLoop
this.clock = new THREE.Clock();
this.renderDelta = 0;
this.logicDelta = 0;
this.sinceLastFrame = 0;
this.justRendered = false;
// Stats (FPS, Frame time, Memory)
this.stats = Stats();
this.params = _.merge(
{
Pointer_Lock: true, // true - false
Mouse_Sensitivity: 0.3, // 0 - 1
Time_Scale: 1, // 0 - 1
Shadows: true, // true - false
FXAA: false, // true - false
Debug_Physics: false, // true - false
Debug_FPS: false, // true - false
Sun_Elevation: 50, // 0 - 180
Sun_Rotation: 150, // 0 - 360
},
worldParams
);
// Initialization
this.inputManager = new InputManager(this, this.renderer.domElement);
this.cameraOperator = new CameraOperator(
this,
this.camera,
this.params.Mouse_Sensitivity
);
this.sky = new Sky(this);
this.sky.phi = this.params.Sun_Elevation;
this.sky.theta = this.params.Sun_Rotation;
if (this.params.Shadows) {
this.sky.csm.lights.forEach((light) => {
light.castShadow = true;
});
} else {
this.sky.csm.lights.forEach((light) => {
light.castShadow = false;
});
}
if (this.params.Debug_Physics) {
this.cannonDebugRenderer = new CannonDebugRenderer(
this.graphicsWorld,
this.physicsWorld
);
}
if (this.params.Debug_FPS) {
document.getElementById("statsBox").style.display = "block";
}
// Load scene if path is supplied
if (worldOptions?.world !== undefined) {
let loadingManager = new LoadingManager(this);
loadingManager.onFinishedCallback = () => {
this.update(1, 1);
this.setTimeScale(1);
this.triggerEvent({
type: EventType.INITIALISE,
targets: [],
});
};
loadingManager.loadGLTF(worldOptions?.world, (gltf) => {
this.loadScene(loadingManager, gltf);
});
} else {
this.triggerEvent({
type: EventType.MISSING_WORLD,
targets: [],
});
}
this.render(this);
}
private onWindowResize(): void {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.fxaaPass.uniforms["resolution"].value.set(
1 / (window.innerWidth * this.pixelRatio),
1 / (window.innerHeight * this.pixelRatio)
);
this.composer.setSize(
window.innerWidth * this.pixelRatio,
window.innerHeight * this.pixelRatio
);
}
// Update
// Handles all logic updates.
public update(timeStep: number, unscaledTimeStep: number): void {
this.updatePhysics(timeStep);
// Update registred objects
this.updatables.forEach((entity) => {
if (entity.update) {
entity.update(timeStep, unscaledTimeStep);
}
});
// Lerp time scale
this.params.Time_Scale = THREE.MathUtils.lerp(
this.params.Time_Scale,
this.timeScaleTarget,
0.2
);
if (this.params.Debug_Physics) this.cannonDebugRenderer.update();
}
public updatePhysics(timeStep: number): void {
// Step the physics world
this.physicsWorld.step(this.physicsFrameTime, timeStep);
this.characters.forEach((char) => {
if (this.isOutOfBounds(char.characterCapsule.body.position)) {
this.outOfBoundsRespawn(char.characterCapsule.body);
}
});
}
public isOutOfBounds(position: CANNON.Vec3): boolean {
const inside =
position.x > -211.882 &&
position.x < 211.882 &&
position.z > -169.098 &&
position.z < 153.232 &&
position.y > 0;
const belowSeaLevel = position.y < 0;
const isOFB = !inside && belowSeaLevel;
return isOFB;
}
public outOfBoundsRespawn(body: CANNON.Body, position?: CANNON.Vec3): void {
let newPos = position || new CANNON.Vec3(0, 16, 0);
let newQuat = new CANNON.Quaternion(0, 0, 0, 1);
body.position.copy(newPos);
body.interpolatedPosition.copy(newPos);
body.quaternion.copy(newQuat);
body.interpolatedQuaternion.copy(newQuat);
body.velocity.setZero();
body.angularVelocity.setZero();
}
public stopRender() {
window.cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
/**
* Rendering loop.
* Implements fps limiter and frame-skipping
* Calls world's "update" function before rendering.
* @param {World} world
*/
public render(world: World): void {
this.requestDelta = this.clock.getDelta();
this.animationFrame = requestAnimationFrame(() => {
world.render(world);
});
// Getting timeStep
let unscaledTimeStep =
this.requestDelta + this.renderDelta + this.logicDelta;
let timeStep = unscaledTimeStep * this.params.Time_Scale;
timeStep = Math.min(timeStep, 1 / 30); // min 30 fps
// Logic
world.update(timeStep, unscaledTimeStep);
// Measuring logic time
this.logicDelta = this.clock.getDelta();
// Frame limiting
let interval = 1 / 60;
this.sinceLastFrame +=
this.requestDelta + this.renderDelta + this.logicDelta;
this.sinceLastFrame %= interval;
// Stats end
this.stats.end();
this.stats.begin();
// Actual rendering with a FXAA ON/OFF switch
if (this.params.FXAA) this.composer.render();
else this.renderer.render(this.graphicsWorld, this.camera);
// Measuring render time
this.renderDelta = this.clock.getDelta();
}
public setTimeScale(value: number): void {
this.params.Time_Scale = value;
this.timeScaleTarget = value;
}
public add(worldEntity: IWorldEntity): void {
worldEntity.addToWorld(this);
this.registerUpdatable(worldEntity);
}
public registerUpdatable(registree: IUpdatable): void {
this.updatables.push(registree);
this.updatables.sort((a, b) => (a.updateOrder > b.updateOrder ? 1 : -1));
}
public remove(worldEntity: IWorldEntity): void {
worldEntity.removeFromWorld(this);
this.unregisterUpdatable(worldEntity);
}
public unregisterUpdatable(registree: IUpdatable): void {
_.pull(this.updatables, registree);
}
public triggerEvent(data: { type: IEventType; targets: string[] }) {
const event = new CustomEvent(MATEMATROLII_EVENT, { detail: data });
window.document.dispatchEvent(event);
}
public loadScene(loadingManager: LoadingManager, gltf: any): void {
gltf.scene.traverse((child) => {
if (child.hasOwnProperty(ModelProps.USER_DATA)) {
const userData = child[ModelProps.USER_DATA];
if (child.type === "Mesh") {
Utils.setupMeshProperties(child, this.worldOptions);
this.sky.csm.setupMaterial(child.material);
}
if (userData.hasOwnProperty(MapDataType.DATA_PROP)) {
if (userData[MapDataType.DATA_PROP] === MapDataType.PHYSICS) {
if (userData.hasOwnProperty(MapType.TYPE_PROP)) {
// Convex doesn't work! Stick to boxes!
if (userData.type === MapType.BOX) {
let phys = new BoxCollider({
size: new THREE.Vector3(
child.scale.x,
child.scale.y,
child.scale.z
),
});
phys.body.position.copy(Utils.cannonVector(child.position));
phys.body.quaternion.copy(Utils.cannonQuat(child.quaternion));
phys.body.computeAABB();
phys.body.shapes.forEach((shape) => {
shape.collisionFilterMask = ~CollisionGroups.TrimeshColliders;
});
this.physicsWorld.addBody(phys.body);
phys = undefined;
} else if (userData.type === MapType.TRIMESH) {
let phys = new TrimeshCollider(child, {});
this.physicsWorld.addBody(phys.body);
phys.body.sleep();
phys = undefined;
}
}
}
if (userData[MapDataType.DATA_PROP] === MapDataType.SCENARIO) {
this.scenarios.push(new Scenario(child, this));
}
}
}
});
this.graphicsWorld.add(gltf.scene);
// Launch default scenario
let defaultScenarioID: string;
for (const scenario of this.scenarios) {
if (scenario.id === this.worldOptions.scenario) {
defaultScenarioID = scenario.id;
break;
}
}
if (defaultScenarioID !== undefined)
this.launchScenario(defaultScenarioID, loadingManager);
}
public launchScenario(
scenarioID: string,
loadingManager?: LoadingManager
): void {
this.lastScenarioID = scenarioID;
this.clearEntities();
// Launch default scenario
if (!loadingManager) loadingManager = new LoadingManager(this);
for (const scenario of this.scenarios) {
if (scenario.id === scenarioID) {
scenario.launch(loadingManager, this);
}
}
}
public restartScenario(): void {
if (this.lastScenarioID !== undefined) {
//document.exitPointerLock();
this.launchScenario(this.lastScenarioID);
} else {
console.warn("Can't restart scenario. Last scenarioID is undefined.");
}
}
public clearEntities(): void {
for (let i = 0; i < this.characters.length; i++) {
this.remove(this.characters[i]);
i--;
}
this.characters = [];
for (let key in this.characterNames) {
this.characterNames[key] = undefined;
}
this.characterNames = {};
for (let i = 0; i < this.items.length; i++) {
this.remove(this.items[i]);
i--;
}
this.items = [];
for (let i = 0; i < this.bullets.length; i++) {
this.remove(this.bullets[i]);
i--;
}
this.bullets = [];
}
public destroy(): void {
this.clearEntities();
window.cancelAnimationFrame(this.animationFrame);
window.removeEventListener("resize", this.onWindowResize);
if (this.graphicsWorld) {
while (this.graphicsWorld.children.length > 0) {
this.graphicsWorld.remove(this.graphicsWorld.children[0]);
}
this.graphicsWorld = undefined;
}
if (this.renderer) {
this.renderer.dispose();
this.renderer.forceContextLoss();
this.renderer = undefined;
}
this.clock?.stop();
this.clock = undefined;
this.camera?.remove();
this.camera = undefined;
this.inputManager.destroy();
this.inputManager = undefined;
this.physicsWorld = undefined;
for (let i = 0; i < this.scenarios.length; i++) {
this.scenarios[i].destroy();
}
this.scenarios = undefined;
this.cannonDebugRenderer = undefined;
this.composer = undefined;
this.stats = undefined;
this.sky.destroy();
this.sky = undefined;
this.parallelPairs = undefined;
this.physicsFrameRate = undefined;
this.physicsFrameTime = undefined;
this.physicsMaxPrediction = undefined;
this.renderDelta = undefined;
this.logicDelta = undefined;
this.requestDelta = undefined;
this.sinceLastFrame = undefined;
this.justRendered = undefined;
this.params = undefined;
this.cameraOperator = undefined;
this.timeScaleTarget = undefined;
this.characters = undefined;
this.characterNames = undefined;
this.items = undefined;
this.bullets = undefined;
this.updatables = undefined;
this.worldOptions = undefined;
this.worldParams = undefined;
this.lastScenarioID = undefined;
this.animationFrame = undefined;
this.pixelRatio = undefined;
this.fxaaPass = undefined;
}
public scrollTheTimeScale(scrollAmount: number): void {
// Changing time scale with scroll wheel
const timeScaleBottomLimit = 0.003;
const timeScaleChangeSpeed = 1.3;
if (scrollAmount > 0) {
this.timeScaleTarget /= timeScaleChangeSpeed;
if (this.timeScaleTarget < timeScaleBottomLimit) this.timeScaleTarget = 0;
} else {
this.timeScaleTarget *= timeScaleChangeSpeed;
if (this.timeScaleTarget < timeScaleBottomLimit)
this.timeScaleTarget = timeScaleBottomLimit;
this.timeScaleTarget = Math.min(this.timeScaleTarget, 1);
}
}
}