UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

630 lines (482 loc) • 18.2 kB
import { Group, LinearEncoding, NoToneMapping, Raycaster as ThreeRaycaster, Scene as ThreeScene, Vector3 as ThreeVector3, VSMShadowMap, WebGLRenderer } from "three"; import { assert } from "../../core/assert.js"; import Signal from "../../core/events/signal/Signal.js"; import Vector1 from "../../core/geom/Vector1.js"; import { max2 } from "../../core/math/max2.js"; import EmptyView from "../../view/elements/EmptyView.js"; import { globalMetrics } from "../metrics/GlobalMetrics.js"; import { MetricsCategory } from "../metrics/MetricsCategory.js"; import { CompositingStages } from "./composit/CompositingStages.js"; import LayerCompositer from "./composit/LayerCompositer.js"; import { Camera } from "./ecs/camera/Camera.js"; import { MaterialManager } from "./material/manager/MaterialManager.js"; import { ColorAndDepthFrameBuffer } from "./render/buffer/buffers/ColorAndDepthFrameBuffer.js"; import { NormalFrameBuffer } from "./render/buffer/buffers/NormalFrameBuffer.js"; import { FrameBufferManager } from "./render/buffer/FrameBufferManager.js"; import { RenderLayerManager } from "./render/layers/RenderLayerManager.js"; import { RenderPassType } from "./render/RenderPassType.js"; import { renderTextureToScreenQuad } from "./render/utils/renderTextureToScreenQuad.js"; import { CameraViewManager } from "./render/view/CameraViewManager.js"; import { ShadowMapRenderer } from "./shadows/ShadowMapRenderer.js"; import { StandardFrameBuffers } from "./StandardFrameBuffers.js"; import { three_setSceneAutoUpdate } from "./three/three_setSceneAutoUpdate.js"; /** * * @param {WebGLRenderer} webGLRenderer */ function configureThreeRenderer(webGLRenderer) { webGLRenderer.autoClear = false; webGLRenderer.setClearColor(0xBBBBFF, 0.0); webGLRenderer.outputEncoding = LinearEncoding; webGLRenderer.toneMapping = NoToneMapping; webGLRenderer.toneMappingExposure = 1; webGLRenderer.state.setFlipSided(false); webGLRenderer.shadowMap.enabled = true; webGLRenderer.shadowMap.type = VSMShadowMap; webGLRenderer.sortObjects = true; //turn off automatic info reset, we use multi-pass rendering, so we manually reset the render info webGLRenderer.info.autoReset = false; } export class GraphicsEngine { #debug = false; get isGraphicsEngine(){ return true; } /** * * @param {Camera} camera * @param {boolean} [debug] * @constructor */ constructor({ camera, debug = false }) { this.#debug = debug; const self = this; /** * * @type {MaterialManager} * @private */ this.__material_manager = new MaterialManager(); // The line below would de-dupe textures, it is a rather slow process however // this.__material_manager.addCompileStep(new CoalesceTextures()); this.on = { preRender: new Signal(), postRender: new Signal(), preOpaquePass: new Signal(), postOpaquePass: new Signal(), /** * @deprecated */ preComposite: new Signal(), /** * @deprecated */ postComposite: new Signal(), buffersRendered: new Signal(), visibilityConstructionStarted: new Signal(), visibilityConstructionEnded: new Signal() }; /** * @type {Vector1} */ this.pixelRatio = new Vector1(1); /** * * @type {RenderLayerManager} */ this.layers = new RenderLayerManager(); //renderer setup const scene = new ThreeScene(); //prevent automatic updates to all descendants of the scene, such updates are very wasteful three_setSceneAutoUpdate(scene, false); //prevent scene matrix from automatically updating, as it would result in updates to the entire scene graph scene.matrixAutoUpdate = false; //setup environment /** * * @type {Scene} */ this.scene = scene; /** * * @type {Group} */ this.visibleGroup = new Group(); this.visibleGroup.matrixAutoUpdate = false; this.scene.add(this.visibleGroup); /** * * @type {Camera} */ this.camera = camera; /** * * @type {WebGLRenderer} */ this.renderer = null; //webGLRenderer.shadowMapDebug = true; this.layerComposer = new LayerCompositer(); Object.defineProperties(this, { info: { get: function () { return self.renderer.info; } } }); /** * @type {View} */ this.viewport = new EmptyView(); this.viewport.size.onChanged.add(this.updateSize, this); this.pixelRatio.onChanged.add(this.updateSize, this); /** * @readonly * @type {FrameBufferManager} */ this.frameBuffers = new FrameBufferManager(); /** * * @type {ShadowMapRenderer} */ this.shadowmap_renderer = new ShadowMapRenderer(); /** * @readonly * @type {CameraViewManager} */ this.views = new CameraViewManager(); /** * * @type {CameraView} */ this.main_view = this.views.create(); this.main_view.name = 'Main Camera'; /** * Used to signal that scene needs to be drawn * When set to true will draw frame at next opportunity * @type {boolean} */ this.needDraw = true; /** * Will automatically draw each frame * @type {boolean} */ this.autoDraw = true; /** * Monotonically increasing rendered-frame counter * @type {number} */ this.frameIndex = 0; this.intersectObjectUnderViewportPoint = (function () { const point = new ThreeVector3(); const raycaster = new ThreeRaycaster(); const origin = new ThreeVector3(); function intersectObject(x, y, object, recurse) { this.viewportProjectionRay(x, y, origin, point); // raycaster.set(origin, point); //console.log(x,y,point.x, point.y, point.z); return raycaster.intersectObject(object, recurse); } return intersectObject; })(); } /** * * @returns {MaterialManager} */ getMaterialManager() { return this.__material_manager; } /** * Get direct access to three.js renderer * @returns {WebGLRenderer} */ getRenderer() { return this.renderer; } updateSize() { const size = this.viewport.size; const renderer = this.renderer; const pixelRatio = this.pixelRatio.getValue(); const _w = max2(0, size.x * pixelRatio); const _h = max2(0, size.y * pixelRatio); renderer.setSize(_w, _h); const devicePixelRatio = window.devicePixelRatio; renderer.setPixelRatio(devicePixelRatio); renderer.domElement.style.width = size.x + "px"; renderer.domElement.style.height = size.y + "px"; this.frameBuffers.setPixelRatio(devicePixelRatio); this.layerComposer.setPixelRatio(devicePixelRatio); this.layerComposer.setSize(_w, _h); this.frameBuffers.setSize(_w, _h); } /** * * @param {Vector2|{set:function(x:number,y:number)}} target */ getResolution(target) { const ar = this.computeTotalPixelRatio(); target.set( this.viewport.size.x * ar, this.viewport.size.y * ar ); } /** * * @returns {number} */ computeTotalPixelRatio() { return this.pixelRatio.getValue() * window.devicePixelRatio; } initializeFrameBuffers() { const colorAndDepthFrameBuffer = new ColorAndDepthFrameBuffer(StandardFrameBuffers.ColorAndDepth); //whole renderer relies on color+depth buffer, so we flag it as always in use to ensure it's always being drawn colorAndDepthFrameBuffer.referenceCount += 1; this.frameBuffers.add(colorAndDepthFrameBuffer); const normalFrameBuffer = new NormalFrameBuffer(StandardFrameBuffers.Normal); this.frameBuffers.add(normalFrameBuffer); //initialize buffers this.frameBuffers.initialize(this.renderer); } start() { const canvas = document.createElement("canvas"); const context = canvas.getContext("webgl2", { antialias: false }); const rendererParameters = { antialias: true, logarithmicDepthBuffer: false, canvas, context, /** * @see https://registry.khronos.org/webgl/specs/latest/1.0/#5.2 */ powerPreference: "high-performance" }; const webGLRenderer = this.renderer = new WebGLRenderer(rendererParameters); //print GPU info const GPU_NAME = this.getGPUName(); console.log("GL renderer : ", GPU_NAME); globalMetrics.record("gpu-type", { category: MetricsCategory.System, label: GPU_NAME }); webGLRenderer.domElement.addEventListener("webglcontextrestored", function (event) { console.warn("webgl cotnext restored", event); }, false); webGLRenderer.domElement.addEventListener("webglcontextlost", function (event) { // By default when a WebGL program loses the context it never gets it back. To recover, we prevent default behaviour event.preventDefault(); console.warn("webgl context lost", event); }, false); const domElement = this.domElement = webGLRenderer.domElement; //disable selection const style = domElement.style; domElement.classList.add("graphics-engine-render-canvas"); style.userSelect = style.webkitUserSelect = style.mozUserSelect = "none"; // see : https://www.w3.org/TR/pointerevents/#the-touch-action-css-property style.touchAction = "none"; configureThreeRenderer(webGLRenderer); //disable shader error checking in production build webGLRenderer.debug.checkShaderErrors = this.#debug; this.enableExtensions(); this.initializeFrameBuffers(); const viewport = this.viewport; const viewportSize = viewport.size; //initialize size webGLRenderer.setSize(viewportSize.x, viewportSize.y); this.frameBuffers.setSize(viewportSize.x, viewportSize.y); viewport.el = webGLRenderer.domElement; } /** * Produces GPU identification string via WebGL extension if available * @returns {string} */ getGPUName() { const gl = this.renderer.getContext(); const ext = gl.getExtension("WEBGL_debug_renderer_info"); if (ext === null) { return "Unknown"; } return gl.getParameter(ext.UNMASKED_RENDERER_WEBGL); } /** * Shut down renderer engine. After calling this method the engine may no longer be used. */ stop() { const renderer = this.renderer; if (renderer !== undefined) { renderer.forceContextLoss(); renderer.context = null; renderer.domElement = null; this.renderer = null; } } /** * Enables various WebGL extensions required by the engine */ enableExtensions() { const ctx = this.renderer.getContext(); // Standard derivatives are required for Terrain Shader ctx.getExtension("OES_standard_derivatives"); // Depth texture is required for Particle Emitter engine ctx.getExtension("WEBGL_depth_texture"); ctx.getExtension("WEBGL_compressed_texture_s3tc"); } /** * Creates a ray from viewport orthogonally into the view frustum * @param {number} x * @param {number} y * @param {Vector3} source Ray source is written here * @param {Vector3} direction Ray target is written here */ viewportProjectionRay(x, y, source, direction) { Camera.projectRay(this.camera, x, y, source, direction); } /** * Converts screen-space pixel position to normalized clip-space * @param {Vector2|Vector3} input * @param {Vector2|Vector3} result */ normalizeViewportPoint(input, result) { assert.notEqual(input, undefined); assert.notEqual(result, undefined); const viewportSize = this.viewport.size; // shift by pixel center const _x = input.x + 0.5; const _y = input.y + 0.5; result.x = (_x / viewportSize.x) * 2 - 1; result.y = -(_y / viewportSize.y) * 2 + 1; } /** * @private * @param {THREE.WebGLRenderer} renderer * @param {THREE.Camera} camera * @param {THREE.Scene} scene */ constructVisibleScene(renderer, camera, scene) { this.on.visibilityConstructionStarted.send3(renderer, camera, scene); // Build visibility information this.main_view.set_from_camera(camera); this.views.build_visibility(this.layers); this.on.visibilityConstructionEnded.send3(renderer, camera, scene); } clearVisibleGroup() { this.visibleGroup.children.length = 0; } /** * * @param {RenderPassType} passType */ prepareRenderPass(passType) { this.clearVisibleGroup(); const visibleGroup = this.visibleGroup; let j = 0; const visible_objects = this.main_view.visible_objects; const element_count = visible_objects.size; const elements = visible_objects.elements; for (let i = 0; i < element_count; i++) { const object3D = elements[i]; const object_pass = classifyPassTypeFromObject(object3D); if (object_pass === passType) { //insert object, bypassing Object#add for speed //visibleGroup.add(object3D); object3D.parent = visibleGroup; visibleGroup.children[j++] = object3D; } } visibleGroup.length = j; } /** * Renders opaque assets */ renderOpaque() { this.on.preOpaquePass.send0(); this.prepareRenderPass(RenderPassType.Opaque); const renderer = this.renderer; const buffers = this.frameBuffers; buffers.render(renderer, this.camera, this.scene); // TODO designate opaque output buffer const frameBuffer = buffers.getById(StandardFrameBuffers.ColorAndDepth); if (frameBuffer === undefined) { throw new Error(`No color-depth frame buffer`); } renderTextureToScreenQuad(frameBuffer.renderTarget.texture, renderer); this.on.postOpaquePass.send0(); } /** * Renders assets that (may) contain transparent fragments */ renderTransparent() { const scene = this.scene; // clear background, as transparent pass is drawn on top, the background should already be composited const _background = scene.background; scene.background = null; this.prepareRenderPass(RenderPassType.Transparent); const renderer = this.renderer; renderer.render(scene, this.camera); // restore background scene.background = _background; } /** * */ render() { if (this.needDraw && !this.autoDraw) { this.needDraw = false; } this.frameIndex++; const renderer = this.renderer; //reset renderer statistics (used for debug) renderer.info.reset(); renderer.autoClear = false; renderer.clearAlpha = 0; //render actual scene const scene = this.scene; const camera = this.camera; if (scene.children.indexOf(camera) < 0) { // console.log("added camera"); scene.add(camera); } this.constructVisibleScene(renderer, camera, scene); //dispatch pre-render event this.on.preRender.send3(renderer, camera, scene); this.main_view.on.preRender.send1(this.main_view); //do the opaque pass this.renderOpaque(); this.layerComposer.composite(renderer, CompositingStages.POST_OPAQUE); this.on.buffersRendered.send3(renderer, camera, scene); // transparent pass this.renderTransparent(); this.layerComposer.composite(renderer, CompositingStages.POST_TRANSPARENT); //dispatch post-render event this.on.postRender.send3(renderer, camera, scene); } } /** * * @param {THREE.Object3D} object * @returns {RenderPassType} */ function classifyPassTypeFromObject(object) { if (object.isMesh || object.isPoints) { /** * @type {THREE.Material} */ const material = object.material; if (material.depthWrite === false && material.depthTest === false) { return RenderPassType.Transparent; } } return RenderPassType.Opaque; }