@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
630 lines (482 loc) • 18.2 kB
JavaScript
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;
}