import * as THREE from 'three'; import { GLTFLoader, DRACOLoader, OrbitControls } from 'three/examples/jsm/Addons.js'; //import { Controller as OrbitControls } from './3rd-party/phy/3TH/Controller.js'; import { ViewportGizmo } from "three-viewport-gizmo"; import Stats from 'three/examples/jsm/libs/stats.module'; import { AnaglyphEffect } from 'three/addons/effects/AnaglyphEffect.js'; import { StereoEffect } from 'three/addons/effects/StereoEffect.js'; // import { MapControls } from 'three/addons/controls/MapControls.js'; // import { FirstPersonControls } from 'three/addons/controls/FirstPersonControls.js'; import { TransformControls } from 'three/addons/controls/TransformControls.js'; import { PointerControls } from './PointerControls'; import { ARButton } from 'three/addons/webxr/ARButton.js'; import { XRButton } from 'three/addons/webxr/XRButton.js'; import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js'; import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'; import { Physics } from './Physics.js'; import { Clickable } from './Clickable.js'; import { DashBoard } from './Dashboard.js'; import { MotionEngine } from './MotionEngine.js'; import { Draggable } from './Draggable.js'; import { EventManager } from "./EventManager"; import { Telemetry } from './Telemetry.js'; import { MeshUtils } from './MeshUtils.js'; THREE.Cache.enabled = true const assetPath = '/asset/default/'; const defaultLightIntensity = 11; const sceneScale = 1.33; class GameEngine extends EventManager{ async initScene(){ this.perspectiveCamera = new THREE.PerspectiveCamera(45, this.aspect, 0.001, 99); //this.perspectiveCamera.position.set(0, 0, 10); this.camera = this.perspectiveCamera; this.frustumSize = 50; this.orthographicCamera = new THREE.OrthographicCamera( this.frustumSize * this.aspect / - 2, this.frustumSize * this.aspect / 2, this.frustumSize / 2, this.frustumSize / - 2, 0.01, 1000 ); const scene = new THREE.Scene(); this.sceneWrapper = new THREE.Group(); scene.add(this.sceneWrapper); this.activeObjects = new THREE.Group(); this.sceneWrapper.add(this.activeObjects); this.activeObjects.scale.setScalar(this.scale); //this.camera.scale.setScalar(0.1); this.scene = scene; this.initCameraPivot() this.ambientLight = new THREE.AmbientLight( 0x404040, defaultLightIntensity ); // soft white light scene.add( this.ambientLight ); // var hemiLight = new THREE.HemisphereLight( 0xffffff, 0x444444, 10 ); // hemiLight.position.set(0, 33, 0); //scene.add( hemiLight ); // scene.fog = new THREE.Fog(0xbbaaaa, 11, 33); // scene.fog = new THREE.FogExp2(0xaaaaaa, 0.037); const dirLight = new THREE.DirectionalLight(0xffffff, 1); dirLight.color.setHSL(0.1, 1, 0.95); dirLight.position.set(12, 33, -37); dirLight.position.multiplyScalar( 0.20 ); // hemiLight.position.multiplyScalar( 0.20 ); scene.add(dirLight); dirLight.castShadow = true; dirLight.shadow.mapSize.width = 1024; dirLight.shadow.mapSize.height = 1024; const d = 50; dirLight.shadow.camera.left = - d; dirLight.shadow.camera.right = d; dirLight.shadow.camera.top = d; dirLight.shadow.camera.bottom = - d; dirLight.shadow.camera.far = 100; dirLight.shadow.bias = - 0.001; // const spotLight = new THREE.SpotLight(0xffffff); // scene.add(spotLight); this.listener = new THREE.AudioListener(); this.camera.add(this.listener); this.ambientSound = new THREE.Audio(this.listener); if (this.opts.gizmo) { this.orbitControls = new OrbitControls(this.camera, this.renderer.domElement); this.orbitControls.enableZoom = false; const gizmo = new ViewportGizmo(this.camera, this.renderer, { container: '.renderer-gizmo', //type:'cube' }); gizmo.attachControls(this.orbitControls); this.gizmo = gizmo; this.perspectiveCamera.position.set(0, 0, 10); this.orthographicCamera.position.set(0, 0, 100); this.cameraRig.rotation.y = 0; } //this.scene.scale.setScalar(1.25); //const controls = new MapControls( camera, renderer.domElement ); if (this.opts.mode == 'GameDesigner'){ const gameEngine = this; this.transformControls = new TransformControls(this.camera, this.renderer.domElement); this.transformControls.addEventListener('dragging-changed', function (event) { if (gameEngine.orbitControls){ gameEngine.orbitControls.enabled = !event.value; } }); scene.add(this.transformControls.getHelper()); }else if (['GamePlay', 'GamePreview'].includes(this.opts.mode)){ const dashboard = new DashBoard(this); this.dashboard = dashboard; dashboard.enable(); } this.pointerControls = new PointerControls(this); scene.background = new THREE.Color(1, 1, 1); const mixer = new THREE.AnimationMixer(this.scene); const timer = new THREE.Timer(); this.timer = timer; this.mixers = [mixer]; await this.initPhysics(); if (this.opts.xr){ this.cameraRig.add(this.xrController1); this.cameraRig.add(this.xrController2); this.cameraRig.add(this.xrControllerGrip1); this.cameraRig.add(this.xrControllerGrip2); const geometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, - 1)]); let line = new THREE.Line(geometry); line.scale.z = 7; this.controllerLine = line; } } async init(domNode, opts = {}) { this.scale = sceneScale; this.w = domNode.clientWidth || 1200, this.h = domNode.clientHeight || 800; this.aspect = this.w / this.h this.opts = opts; this.raycaster = new THREE.Raycaster(); this.meshUtils = new MeshUtils(this); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true, //this is important for screenshots capturing //powerPreference: "high-performance", //precision: 'mediump' }); renderer.setPixelRatio(window.devicePixelRatio); renderer.toneMapping = THREE.CineonToneMapping; renderer.toneMappingExposure = 1.0; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.VSMShadowMap; // default THREE.PCFShadowMap // renderer.toneMapping = THREE.ACESFilmicToneMapping; // renderer.toneMappingExposure = 1; // renderer.shadowMap.enabled = true; // renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.setSize(this.w, this.h); renderer.setViewport(0, 0, this.w, this.h); renderer.autoClear = true; //renderer.alpha = true this.renderer = renderer; this.pmremGenerator = new THREE.PMREMGenerator(renderer); this.anaglyph = new AnaglyphEffect(renderer); this.anaglyph.setSize(this.w, this.h); this.stereo = new StereoEffect(renderer); this.stereo.setSize(this.w, this.h); this.motionQueue = new MotionEngine(); this.assetPath = assetPath; this.tm = new Telemetry(opts.mode); // controls.enableDamping = true; // controls.screenSpacePanning = true; this.renderType = 'ST'; this.handleXrAction = ['ObjectPreview', 'DesignMode'].includes(opts.mode) ? this.handleXrActionDesignMode : this.handleXrActionGameMode; if (opts.stats){ this.stats = new Stats(); this.stats.dom.classList.add('engine-stats'); document.body.appendChild(this.stats.dom); } if (opts.ar) { renderer.xr.enabled = true; this.arBtn = ARButton.createButton(renderer, {}); this.arBtn.classList.add('engine-ar-btn'); document.body.appendChild(this.arBtn); } if (opts.xr) { renderer.xr.enabled = true; this.xrBtn = XRButton.createButton(renderer, opts.depthSense ? { requiredFeatures: ['depth-sensing'], depthSensing: { usagePreference: ["gpu-optimized"], dataFormatPreference: ["unsigned-short"], matchDepthView: false } } : { }) this.xrBtn.classList.add('engine-xr-btn'); document.body.appendChild(this.xrBtn); this.initXr(); } this.clickable = new Clickable(this, 20); this.draggable = new Draggable(this, 20); await this.initScene(); function animate() { this.timer.update(); let delta = this.timer.getDelta(); this.physics?.step(); this.handleXrAction(this, delta); this.hero?.update(delta); this.mixers.forEach(m => m.update(delta)); this.dispatchEvent({type: 'beforeRender'}) this.processHideIfFar(); this.motionQueue.update(delta); this.render(this.scene, this.camera); if (!renderer.xr.isPresenting) { this.gizmo?.render(); } } renderer.setAnimationLoop(animate.bind(this)); this.loadedObjects = []; domNode.appendChild(renderer.domElement); this._wheelEvent = ((event) => { event.preventDefault(); if (this.hero){ if (!this.pointerControls.isLocked){ this.hero.cameraZ += event.deltaY * 0.005; } }else{ this.camera.zoom -= event.deltaY / (1000 / this.camera.zoom); this.camera.zoom = Math.max(this.camera.zoom, .01); //controls.rotateSpeed = 1 / Math.sqrt(gameEngine.camera.zoom); this.camera.updateProjectionMatrix(); this.orbitControls.panSpeed = 1 / this.camera.zoom; } }).bind(this) renderer.domElement.addEventListener('wheel', this._wheelEvent) GameEngine.ktxLoader.detectSupport(renderer); } initXr() { this.xrHandlers = { onControllerEvent: (event=>{ const controller = event.target; if (!controller.userData?.active) return; if (this.opts.designMode){ this.transformControls.getRaycaster().setFromXRController(controller); switch (event.type) { case 'selectstart': this.transformControls.pointerDown(null); break; case 'selectend': this.transformControls.pointerUp(null); break; case 'move': this.transformControls.pointerHover(null); this.transformControls.pointerMove(null); break; } }else{ this.draggable?.handleController(controller, event.type) } }).bind(this), onSelect: (event => { const controller = event.target; this.xrController1.userData.active = false; this.xrController2.userData.active = false; if (controller === this.xrController1) { this.xrController1.userData.active = true; this.xrController1.add(this.controllerLine); } if (controller === this.xrController2) { this.xrController2.userData.active = true; this.xrController2.add(this.controllerLine); } if (this.opts.designMode){ this.raycaster.setFromXRController(controller); const intersects = this.raycaster.intersectObjects(this.activeObjects.children, true); intersects.forEach(o => { while (o.object && !this.activeObjects.children.includes(o.object)) { o.object = o.object.parent; } }) if (intersects.length > 0) { setTimeout(() => { this.transformControls.attach(intersects[0].object); }, 100); } }else{ this.clickable.handleController(controller, event); } }).bind(this), onConnect: e => { e.target.gamepad = e.data.gamepad; } } let c1 = this.renderer.xr.getController(0); c1.userData.active = false; let c2 = this.renderer.xr.getController(1); c2.userData.active = true; [c1, c2].forEach(c=>{ c.addEventListener('select', this.xrHandlers.onSelect); c.addEventListener('selectstart', this.xrHandlers.onControllerEvent); c.addEventListener('selectend', this.xrHandlers.onControllerEvent); c.addEventListener('move', this.xrHandlers.onControllerEvent); c.addEventListener('connected', this.xrHandlers.onConnect) }) const controllerModelFactory = new XRControllerModelFactory(); let controllerGrip1 = this.renderer.xr.getControllerGrip(0); controllerGrip1.add(controllerModelFactory.createControllerModel(controllerGrip1)); let controllerGrip2 = this.renderer.xr.getControllerGrip(1); controllerGrip2.add(controllerModelFactory.createControllerModel(controllerGrip2)); this.xrController1 = c1 this.xrController2 = c2 this.xrControllerGrip1 = controllerGrip1; this.xrControllerGrip2 = controllerGrip2; this.xrCamera = this.renderer.xr.getCamera(); } disposeXr(){ [this.xrController1, this.xrController2].forEach(c=>{ c.removeEventListener('select', this.xrHandlers.onSelect); c.removeEventListener('selectstart', this.xrHandlers.onControllerEvent); c.removeEventListener('selectend', this.xrHandlers.onControllerEvent); c.removeEventListener('move', this.xrHandlers.onControllerEvent); c.removeEventListener('connected', this.xrHandlers.onConnect); }) } initCameraPivot() { this.cameraWorld = new THREE.Group(); this.cameraRig = new THREE.Group(); this.cameraRig.add(this.perspectiveCamera); this.cameraRig.add(this.orthographicCamera); this.cameraRig.rotation.y = Math.PI; this.cameraWorld.add(this.cameraRig); this.sceneWrapper.add(this.cameraWorld); } async initPhysics() { this.physics = new Physics(this); await this.physics.init(); } handleXrActionGameMode(gameEngine, delta) { } handleXrActionDesignMode(gameEngine, delta) { if (gameEngine.xrController2?.gamepad) { let gp2 = gameEngine.xrController2.gamepad; let gp1 = gameEngine.xrController1.gamepad; // if (gp.axes[3] != 0){ // let sc = gameEngine.scene.scale.x + gp.axes[3] * delta * 0.5; // gameEngine.scene.scale.set(sc, sc, sc); // } if (gp2.axes[2] != 0 || gp2.axes[3] != 0) { if (gp1.buttons[4]?.pressed) { gameEngine.activeObjects.position.y += gp2.axes[2] * delta; } else if (gp1.buttons[5]?.pressed) { let sc = gameEngine.activeObjects.scale.x * (gp2.axes[2] * delta * 0.5 + 1) * (gp2.axes[3] * delta * 0.05 + 1); gameEngine.activeObjects.scale.setScalar(sc); } else { gameEngine.activeObjects.rotation.y += gp2.axes[2] * delta * 0.5 + gp2.axes[3] * delta * 0.05; } } if (gp2.buttons[5]?.pressed && (gp1.axes[3] != 0 || gp1.axes[2] != 0)) { gameEngine.ambientLight.intensity += gp1.axes[3] * delta * 5 + gp1.axes[2] * delta; return; } if (gp2.buttons[4]?.pressed) { let x = performance.now() % 1000; if (x < 333) { gameEngine.transformControls?.setMode('translate'); }else if (x < 666) { gameEngine.transformControls?.setMode('scale'); }else{ gameEngine.transformControls?.setMode('rotate'); } } } if (gameEngine.xrController1?.gamepad) { let gp1 = gameEngine.xrController1.gamepad; if (gp1.axes[3] != 0) { gameEngine.activeObjects.position.z += (Math.exp(1+Math.abs(gp1.axes[3]))-2) * delta * Math.sign(gp1.axes[3]) * 0.2; } if (gp1.axes[2] != 0) { gameEngine.activeObjects.position.x += (Math.exp(1+Math.abs(gp1.axes[2]))-2) * delta * Math.sign(gp1.axes[2]) * 0.2; } } } $ = THREE; async load(url, path = assetPath, progress) { return new Promise((resolve, reject) => { GameEngine.gltfLoader.load(`${path}${url}`, o => { o.scene.traverse(object => { if (object.name == 'environment' || object.material){ //console.log('env recomputing') object.material.shading = THREE.SmoothShading; object.geometry.computeVertexNormals(true); //object.material.metalness = 0; //if (object.material.map) object.material.map.colorSpace = THREE.SRGBColorSpace; } if ( object instanceof THREE.Mesh ) { //object.material.envMap = this.scene.environment; if (object.material.map) { object.material.map.colorSpace = THREE.SRGBColorSpace; } if (object.userData){ if (object.userData['re.renderOrder'] !== undefined){ object.renderOrder = object.userData['re.renderOrder']; //object.material.alphaTest = true; } } } object.frustumCulled = false; object.castShadow = true; object.receiveShadow = true; }); resolve(o); this.loadedObjects.push(o); }, progress, reject) }) } async loadPanorama(url, path = assetPath) { let t = await GameEngine.loadTexture(url, path); t.mapping = THREE.EquirectangularReflectionMapping; t.generateMipmaps = false; t.minFilter = THREE.LinearFilter; this.scene.background?.dispose?.(); this.scene.environment?.dispose?.(); this.scene.background = t; this.scene.environment = t; //this.scene.environment = this.pmremGenerator.fromEquirectangular(t).texture; } async captureScreenshot(type = 'image/webp', quality = 80) { return new Promise((resolve, reject) => { this.renderer.domElement.toBlob(resolve, type, quality) }) } playAnimation(object, clip, play = true) { let action = this.mixers[0].clipAction(clip, object); if (play) action.play(); else action.stop(); } stop() { this.renderer?.setAnimationLoop(null); this.stats?.dom?.remove(); } getMouseVector(mouseEvent, domElement) { //console.log(mouseEvent, domElement) const mouse = new THREE.Vector2(); if (this.renderType == 'VR') { let x; if (mouseEvent.offsetX > window.innerWidth / 2) { x = (mouseEvent.offsetX - window.innerWidth / 2) * 2 } else { x = mouseEvent.offsetX * 2; } mouse.x = (x / window.innerWidth) * 2 - 1; mouse.y = - (mouseEvent.offsetY / window.innerHeight) * 2 + 1; } else { mouse.x = (mouseEvent.offsetX / domElement.clientWidth) * 2 - 1; mouse.y = - (mouseEvent.offsetY / domElement.clientHeight) * 2 + 1; } return mouse; } intersect(mouseEvent, domElement, objects, recursive = false, returnInputObjects = true) { let mouse = this.getMouseVector(mouseEvent, domElement); this.raycaster.setFromCamera(mouse, this.camera); let intersects = this.raycaster.intersectObjects(objects.filter(o => o.visible), recursive); if (returnInputObjects && recursive) { intersects.forEach(o => { while (o.object && !objects.includes(o.object)) { o.object = o.object.parent; } }) } //console.log('intersects', intersects); return intersects; } onClick(mouseEvent, domElement){ let mouse = this.getMouseVector(mouseEvent, domElement); //this.raycaster.setFromCamera(mouse, this.camera); this.clickable.handleMouse(mouse, mouseEvent); this.hero?.idleReset(); } onPointer(mouseEvent, domElement, type){ let mouse = this.getMouseVector(mouseEvent, domElement); this.draggable?.handleMouse(mouse, type); } setCamera(camera) { //camera.updateProjectionMatrix(); this.camera = camera; //this.transformControls.camera = camera; this.orbitControls.object = camera; } setCameraOrthographic() { let o = this.orthographicCamera; // o.position.copy(o.position); // const distance = o.position.distanceTo(this.orbitControls.target); // const halfWidth = this.frustumWidthAtDistance(o, distance) / 2; // const halfHeight = this.frustumHeightAtDistance(o, distance) / 2; // o.top = halfHeight; // o.bottom = -halfHeight; // o.left = -halfWidth; // o.right = halfWidth; // o.zoom = 1; // o.lookAt(this.orbitControls.target); // o.updateProjectionMatrix(); this.camera = o; this.transformControls.camera = o; //this.orbitControls.object = o; if (this.gizmo) { this.gizmo.camera = o; } } setCameraPerspective() { let o = this.perspectiveCamera; // const oldY = o.position.y; // o.position.copy(o.position); // o.position.y = oldY / o.zoom; // o.updateProjectionMatrix(); this.camera = o; this.transformControls.camera = o; //this.orbitControls.object = o; if (this.gizmo) { this.gizmo.camera = o; } } resize(w, h) { this.w = w; this.h = h; this.aspect = this.w / this.h this.perspectiveCamera.aspect = this.aspect; this.perspectiveCamera.updateProjectionMatrix(); this.orthographicCamera.left = - this.frustumSize * this.aspect / 2; this.orthographicCamera.right = this.frustumSize * this.aspect / 2; this.orthographicCamera.top = this.frustumSize / 2; this.orthographicCamera.bottom = - this.frustumSize / 2; this.renderer.setSize(w, h); this.renderer.setViewport(0, 0, this.w, this.h); this.anaglyph.setSize(w, h); this.stereo.setSize(w, h); this.gizmo?.update(); } render(scene, camera) { if (this.renderType == 'VR') { this.stereo?.render(scene, camera); } else if (this.renderType == 'AG') { this.anaglyph?.render(scene, camera); } else { this.renderer.render(scene, camera); } this.stats?.update() } farArray = []; hideIfFar(object, distance){ object.visible = false; this.farArray.push({object, distance}) } processHideIfFar(){ let v = new THREE.Vector3(), cv = new THREE.Vector3(); this.cameraWorld.getWorldPosition(cv); this.farArray.forEach(e=>{ e.object.getWorldPosition(v); let dst = v.distanceTo(cv) / this.scale; if (dst <= e.distance && !e.object.visible) { e.object.visible = true; }else if (dst > e.distance && e.object.visible){ e.object.visible = false; } }) } async playAmbientSound(source, path){ let buffer = await GameEngine.loadAudio(source, path); this.ambientSound.setBuffer(buffer); this.ambientSound.setLoop(true); this.ambientSound.play(); } immersive(show, t = 1){ this.motionQueue.add([ { o: this.scene, a: { backgroundIntensity: show ? 0.1 : 1, environmentIntensity: show ? 0.1 : 1 }, t },{ o: this.ambientLight, a: { intensity: show ? 0.1 : defaultLightIntensity}, t } ]) } clearScene(){ this.hero?.dispose(); this.transformControls?.dispose(); this.pointerControls.dispose(); //this.activeObjects.clear(); this.physics.stop(); this.physics.clear(); this.clickable.removeAll(); this.gizmo?.dispose(); this.motionQueue.clearAll(); this.ambientSound.stop(); this.loadedObjects.forEach(o=>this.meshUtils.clearObject(o.scene)) GameEngine.loadedTextures.forEach(t=>{ t.dispose(); }); GameEngine.loadedTextures.splice(0, GameEngine.loadedTextures.length); this.scene.background?.dispose?.(); this.scene.environment?.dispose?.(); this.meshUtils.clearObject(this.scene); this.scene = null; this.tm?.setScene(null); this.removeAllListenersOfType('beforeRender'); } async resetScene(){ this.dashboard?.reset();//moved from clearscene to resetscene, because an updateText sync callback waits infinitely after gameengine destroy this.clearScene(); await this.initScene(); } dispose(){ this.stop(); this.clearScene(); this.disposeXr(); this.renderer.dispose(); this.pmremGenerator.dispose(); this.arBtn?.remove(); this.xrBtn?.remove(); this.stats?.dom?.remove(); this.renderer.renderLists.dispose(); this.renderer.properties.dispose(); this.renderer.domElement.removeEventListener('wheel', this._wheelEvent) this.renderer.domElement.remove(); super.dispose(); //console.log('Engine Disposed', this.renderer.info.memory.textures ); } static textureLoader = new THREE.TextureLoader(); static audioLoader = new THREE.AudioLoader(); static draco = new DRACOLoader().setDecoderPath('/3rdparty/draco/'); static ktxLoader = new KTX2Loader().setTranscoderPath( '/3rdparty/basis/' ); static gltfLoader = new GLTFLoader().setDRACOLoader(this.draco).setKTX2Loader(this.ktxLoader); static loadedTextures = [] static async loadTexture(url, path = assetPath, progress, assignTo) { let loader = url.toLowerCase().endsWith('.ktx2') ? GameEngine.ktxLoader : GameEngine.textureLoader; return new Promise((resolve, reject) => { loader.load(`${path}${url}`, texture => { texture.colorSpace = THREE.SRGBColorSpace; texture.name = url; if (assignTo){ assignTo[0][assignTo[1]] = texture; } resolve(texture) GameEngine.loadedTextures.push(texture); }, progress, reject) }) } static async loadAudio(url, path = assetPath, progress){ return new Promise((resolve, reject) => { GameEngine.audioLoader.load(`${path}${url}`, buffer => { resolve(buffer) }, progress, reject) }) } static scale = 1.33; loadTexture = GameEngine.loadTexture } export { GameEngine }