UNPKG

playcanvas

Version:

Open-source WebGL/WebGPU 3D engine for the web

801 lines (800 loc) 24.8 kB
import { platform } from "../core/platform.js"; import { now } from "../core/time.js"; import { path } from "../core/path.js"; import { TRACEID_RENDER_FRAME, TRACEID_RENDER_FRAME_TIME } from "../core/constants.js"; import { EventHandler } from "../core/event-handler.js"; import { Color } from "../core/math/color.js"; import { Mat4 } from "../core/math/mat4.js"; import { math } from "../core/math/math.js"; import { Quat } from "../core/math/quat.js"; import { Vec3 } from "../core/math/vec3.js"; import { CULLFACE_NONE, SHADERLANGUAGE_GLSL, SHADERLANGUAGE_WGSL } from "../platform/graphics/constants.js"; import { http } from "../platform/net/http.js"; import { LAYERID_DEPTH, LAYERID_IMMEDIATE, LAYERID_SKYBOX, LAYERID_UI, LAYERID_WORLD, SORTMODE_NONE, SORTMODE_MANUAL } from "../scene/constants.js"; import { setProgramLibrary } from "../scene/shader-lib/get-program-library.js"; import { ProgramLibrary } from "../scene/shader-lib/program-library.js"; import { ForwardRenderer } from "../scene/renderer/forward-renderer.js"; import { FrameGraph } from "../scene/frame-graph.js"; import { AreaLightLuts } from "../scene/area-light-luts.js"; import { Layer } from "../scene/layer.js"; import { LayerComposition } from "../scene/composition/layer-composition.js"; import { Scene } from "../scene/scene.js"; import { ShaderMaterial } from "../scene/materials/shader-material.js"; import { StandardMaterial } from "../scene/materials/standard-material.js"; import { setDefaultMaterial } from "../scene/materials/default-material.js"; import { FILLMODE_FILL_WINDOW, FILLMODE_KEEP_ASPECT, RESOLUTION_AUTO, RESOLUTION_FIXED } from "./constants.js"; import { Asset } from "./asset/asset.js"; import { AssetRegistry } from "./asset/asset-registry.js"; import { BundleRegistry } from "./bundle/bundle-registry.js"; import { ComponentSystemRegistry } from "./components/registry.js"; import { BundleHandler } from "./handlers/bundle.js"; import { ResourceLoader } from "./handlers/loader.js"; import { I18n } from "./i18n/i18n.js"; import { ScriptRegistry } from "./script/script-registry.js"; import { Entity } from "./entity.js"; import { SceneRegistry } from "./scene-registry.js"; import { script } from "./script.js"; import { ApplicationStats } from "./stats.js"; import { getApplication, setApplication } from "./globals.js"; import { shaderChunksGLSL } from "../scene/shader-lib/glsl/collections/shader-chunks-glsl.js"; import { shaderChunksWGSL } from "../scene/shader-lib/wgsl/collections/shader-chunks-wgsl.js"; import { ShaderChunks } from "../scene/shader-lib/shader-chunks.js"; let app = null; class AppBase extends EventHandler { _batcher = null; _destroyRequested = false; _inFrameUpdate = false; _librariesLoaded = false; _fillMode = FILLMODE_KEEP_ASPECT; _resolutionMode = RESOLUTION_FIXED; _allowResize = true; _skyboxAsset = null; _soundManager; _visibilityChangeHandler; _entityIndex = {}; _inTools = false; _scriptPrefix = ""; _time = 0; enableBundles = typeof TextDecoder !== "undefined"; frameRequestId; tick = (timestamp, xrFrame) => { if (!this.graphicsDevice) { return; } if (this.frameRequestId) { this.xr?.session?.cancelAnimationFrame(this.frameRequestId); cancelAnimationFrame(this.frameRequestId); this.frameRequestId = null; } this._inFrameUpdate = true; setApplication(this); app = this; const currentTime = this._processTimestamp(timestamp) || now(); const ms = currentTime - (this._time || currentTime); let dt = ms / 1e3; dt = math.clamp(dt, 0, this.maxDeltaTime); dt *= this.timeScale; this._time = currentTime; this.requestAnimationFrame(); if (this.graphicsDevice.contextLost) { return; } this.stats.updateBasic(currentTime, dt, ms, this.renderer, this.graphicsDevice); this.fire("frameupdate", ms); let skipUpdate = false; if (xrFrame) { skipUpdate = !this.xr?.update(xrFrame); } if (!skipUpdate) { this.update(dt); this.fire("framerender"); if (this.autoRender || this.renderNextFrame) { this.render(); this.renderNextFrame = false; } this.fire("frameend"); this.stats.frameEnd(); } this._inFrameUpdate = false; if (this._destroyRequested) { this.destroy(); } }; timeScale = 1; maxDeltaTime = 0.1; // Maximum delta is 0.1s or 10 fps. frame = 0; frameGraph = new FrameGraph(); renderer; scriptsOrder = []; stats; autoRender = true; renderNextFrame = false; graphicsDevice; root; scene; lightmapper = null; loader = new ResourceLoader(this); assets; bundles; scenes = new SceneRegistry(this); scripts = new ScriptRegistry(this); systems = new ComponentSystemRegistry(); i18n = new I18n(this); keyboard = null; mouse = null; touch = null; gamepads = null; elementInput = null; xr = null; constructor(canvas) { super(); AppBase._applications[canvas.id] = this; setApplication(this); app = this; this.root = new Entity(); this.root._enabledInHierarchy = true; } init(appOptions) { const { assetPrefix, batchManager, componentSystems, elementInput, gamepads, graphicsDevice, keyboard, lightmapper, mouse, resourceHandlers, scriptsOrder, scriptPrefix, soundManager, touch, xr } = appOptions; this.graphicsDevice = graphicsDevice; ShaderChunks.get(graphicsDevice, SHADERLANGUAGE_GLSL).add(shaderChunksGLSL); ShaderChunks.get(graphicsDevice, SHADERLANGUAGE_WGSL).add(shaderChunksWGSL); this._initDefaultMaterial(); this._initProgramLibrary(); this.stats = new ApplicationStats(graphicsDevice); this._soundManager = soundManager; this.scene = new Scene(graphicsDevice); this._registerSceneImmediate(this.scene); this.assets = new AssetRegistry(this.loader); if (assetPrefix) this.assets.prefix = assetPrefix; this.bundles = new BundleRegistry(this.assets); this.scriptsOrder = scriptsOrder || []; this.defaultLayerWorld = new Layer({ name: "World", id: LAYERID_WORLD }); this.defaultLayerDepth = new Layer({ name: "Depth", id: LAYERID_DEPTH, enabled: false, opaqueSortMode: SORTMODE_NONE }); this.defaultLayerSkybox = new Layer({ name: "Skybox", id: LAYERID_SKYBOX, opaqueSortMode: SORTMODE_NONE }); this.defaultLayerUi = new Layer({ name: "UI", id: LAYERID_UI, transparentSortMode: SORTMODE_MANUAL }); this.defaultLayerImmediate = new Layer({ name: "Immediate", id: LAYERID_IMMEDIATE, opaqueSortMode: SORTMODE_NONE }); const defaultLayerComposition = new LayerComposition("default"); defaultLayerComposition.pushOpaque(this.defaultLayerWorld); defaultLayerComposition.pushOpaque(this.defaultLayerDepth); defaultLayerComposition.pushOpaque(this.defaultLayerSkybox); defaultLayerComposition.pushTransparent(this.defaultLayerWorld); defaultLayerComposition.pushOpaque(this.defaultLayerImmediate); defaultLayerComposition.pushTransparent(this.defaultLayerImmediate); defaultLayerComposition.pushTransparent(this.defaultLayerUi); this.scene.layers = defaultLayerComposition; AreaLightLuts.createPlaceholder(graphicsDevice); this.renderer = new ForwardRenderer(graphicsDevice, this.scene); if (lightmapper) { this.lightmapper = new lightmapper(graphicsDevice, this.root, this.scene, this.renderer, this.assets); this.once("prerender", this._firstBake, this); } if (batchManager) { this._batcher = new batchManager(graphicsDevice, this.root, this.scene); this.once("prerender", this._firstBatch, this); } this.keyboard = keyboard || null; this.mouse = mouse || null; this.touch = touch || null; this.gamepads = gamepads || null; if (elementInput) { this.elementInput = elementInput; this.elementInput.app = this; } this.xr = xr ? new xr(this) : null; if (this.elementInput) this.elementInput.attachSelectEvents(); this._scriptPrefix = scriptPrefix || ""; if (this.enableBundles) { this.loader.addHandler("bundle", new BundleHandler(this)); } resourceHandlers.forEach((resourceHandler) => { const handler = new resourceHandler(this); this.loader.addHandler(handler.handlerType, handler); }); this.loader.enableRetry(); componentSystems.forEach((componentSystem) => { this.systems.add(new componentSystem(this)); }); this._visibilityChangeHandler = this.onVisibilityChange.bind(this); if (typeof document !== "undefined") { document.addEventListener("visibilitychange", this._visibilityChangeHandler, false); } } static _applications = {}; static getApplication(id) { return id ? AppBase._applications[id] : getApplication(); } _initDefaultMaterial() { const material = new StandardMaterial(); material.name = "Default Material"; setDefaultMaterial(this.graphicsDevice, material); } _initProgramLibrary() { const library = new ProgramLibrary(this.graphicsDevice, new StandardMaterial()); setProgramLibrary(this.graphicsDevice, library); } get soundManager() { return this._soundManager; } get batcher() { return this._batcher; } get fillMode() { return this._fillMode; } get resolutionMode() { return this._resolutionMode; } configure(url, callback) { http.get(url, (err, response) => { if (err) { callback(err); return; } const props = response.application_properties; const scenes = response.scenes; const assets = response.assets; this._parseApplicationProperties(props, (err2) => { this._parseScenes(scenes); this._parseAssets(assets); if (!err2) { callback(null); } else { callback(err2); } }); }); } preload(callback) { this.fire("preload:start"); const assets = this.assets.list({ preload: true }); if (assets.length === 0) { this.fire("preload:end"); callback(); return; } let loadedCount = 0; const onAssetLoadOrError = () => { loadedCount++; this.fire("preload:progress", loadedCount / assets.length); if (loadedCount === assets.length) { this.fire("preload:end"); callback(); } }; assets.forEach((asset) => { if (!asset.loaded) { asset.once("load", onAssetLoadOrError); asset.once("error", onAssetLoadOrError); this.assets.load(asset); } else { onAssetLoadOrError(); } }); } _preloadScripts(sceneData, callback) { callback(); } // set application properties from data file _parseApplicationProperties(props, callback) { if (typeof props.maxAssetRetries === "number" && props.maxAssetRetries > 0) { this.loader.enableRetry(props.maxAssetRetries); } if (!props.useDevicePixelRatio) { props.useDevicePixelRatio = props.use_device_pixel_ratio; } if (!props.resolutionMode) { props.resolutionMode = props.resolution_mode; } if (!props.fillMode) { props.fillMode = props.fill_mode; } this._width = props.width; this._height = props.height; if (props.useDevicePixelRatio) { this.graphicsDevice.maxPixelRatio = window.devicePixelRatio; } this.setCanvasResolution(props.resolutionMode, this._width, this._height); this.setCanvasFillMode(props.fillMode, this._width, this._height); if (props.layers && props.layerOrder) { const composition = new LayerComposition("application"); const layers = {}; for (const key in props.layers) { const data = props.layers[key]; data.id = parseInt(key, 10); data.enabled = data.id !== LAYERID_DEPTH; layers[key] = new Layer(data); } for (let i = 0, len = props.layerOrder.length; i < len; i++) { const sublayer = props.layerOrder[i]; const layer = layers[sublayer.layer]; if (!layer) continue; if (sublayer.transparent) { composition.pushTransparent(layer); } else { composition.pushOpaque(layer); } composition.subLayerEnabled[i] = sublayer.enabled; } this.scene.layers = composition; } if (props.batchGroups) { const batcher = this.batcher; if (batcher) { for (let i = 0, len = props.batchGroups.length; i < len; i++) { const grp = props.batchGroups[i]; batcher.addGroup(grp.name, grp.dynamic, grp.maxAabbSize, grp.id, grp.layers); } } } if (props.i18nAssets) { this.i18n.assets = props.i18nAssets; } this._loadLibraries(props.libraries, callback); } _loadLibraries(urls, callback) { const len = urls.length; let count = len; const regex = /^https?:\/\//; if (len) { const onLoad = (err, script2) => { count--; if (err) { callback(err); } else if (count === 0) { this.onLibrariesLoaded(); callback(null); } }; for (let i = 0; i < len; ++i) { let url = urls[i]; if (!regex.test(url.toLowerCase()) && this._scriptPrefix) { url = path.join(this._scriptPrefix, url); } this.loader.load(url, "script", onLoad); } } else { this.onLibrariesLoaded(); callback(null); } } _parseScenes(scenes) { if (!scenes) return; for (let i = 0; i < scenes.length; i++) { this.scenes.add(scenes[i].name, scenes[i].url); } } _parseAssets(assets) { const list = []; const scriptsIndex = {}; const bundlesIndex = {}; for (let i = 0; i < this.scriptsOrder.length; i++) { const id = this.scriptsOrder[i]; if (!assets[id]) { continue; } scriptsIndex[id] = true; list.push(assets[id]); } if (this.enableBundles) { for (const id in assets) { if (assets[id].type === "bundle") { bundlesIndex[id] = true; list.push(assets[id]); } } } for (const id in assets) { if (scriptsIndex[id] || bundlesIndex[id]) { continue; } list.push(assets[id]); } for (let i = 0; i < list.length; i++) { const data = list[i]; const asset = new Asset(data.name, data.type, data.file, data.data); asset.id = parseInt(data.id, 10); asset.preload = data.preload ? data.preload : false; asset.loaded = data.type === "script" && data.data && data.data.loadingType > 0; asset.tags.add(data.tags); if (data.i18n) { for (const locale in data.i18n) { asset.addLocalizedAssetId(locale, data.i18n[locale]); } } this.assets.add(asset); } } start() { this.frame = 0; this.fire("start", { timestamp: now(), target: this }); if (!this._librariesLoaded) { this.onLibrariesLoaded(); } this.systems.fire("initialize", this.root); this.fire("initialize"); this.systems.fire("postInitialize", this.root); this.systems.fire("postPostInitialize", this.root); this.fire("postinitialize"); this.requestAnimationFrame(); } requestAnimationFrame() { if (this.xr?.session) { this.frameRequestId = this.xr.session.requestAnimationFrame(this.tick); } else { this.frameRequestId = platform.browser || platform.worker ? requestAnimationFrame(this.tick) : null; } } inputUpdate(dt) { if (this.mouse) { this.mouse.update(); } if (this.keyboard) { this.keyboard.update(); } if (this.gamepads) { this.gamepads.update(); } } update(dt) { this.frame++; this.graphicsDevice.update(); this.stats.frame.scriptUpdateStart = now(); this.systems.fire(this._inTools ? "toolsUpdate" : "update", dt); this.stats.frame.scriptUpdate = now() - this.stats.frame.scriptUpdateStart; this.stats.frame.animUpdateStart = now(); this.systems.fire("animationUpdate", dt); this.stats.frame.animUpdate = now() - this.stats.frame.animUpdateStart; this.stats.frame.scriptPostUpdateStart = now(); this.systems.fire("postUpdate", dt); this.stats.frame.scriptPostUpdate = now() - this.stats.frame.scriptPostUpdateStart; this.fire("update", dt); this.inputUpdate(dt); } render() { this.updateCanvasSize(); this.graphicsDevice.frameStart(); this.fire("prerender"); this.root.syncHierarchy(); if (this._batcher) { this._batcher.updateAll(); } this.renderComposition(this.scene.layers); this.fire("postrender"); this.stats.frame.renderTime = now() - this.stats.frame.renderStart; this.graphicsDevice.frameEnd(); } // render a layer composition renderComposition(layerComposition) { this.renderer.update(layerComposition); this.renderer.buildFrameGraph(this.frameGraph, layerComposition); this.frameGraph.render(this.graphicsDevice); } setCanvasFillMode(mode, width, height) { this._fillMode = mode; this.resizeCanvas(width, height); } setCanvasResolution(mode, width, height) { this._resolutionMode = mode; if (mode === RESOLUTION_AUTO && width === void 0) { width = this.graphicsDevice.canvas.clientWidth; height = this.graphicsDevice.canvas.clientHeight; } this.graphicsDevice.resizeCanvas(width, height); } isHidden() { return document.hidden; } onVisibilityChange() { if (this.isHidden()) { if (this._soundManager) { this._soundManager.suspend(); } } else { if (this._soundManager) { this._soundManager.resume(); } } } resizeCanvas(width, height) { if (!this._allowResize) return void 0; if (this.xr && this.xr.session) { return void 0; } const windowWidth = window.innerWidth; const windowHeight = window.innerHeight; if (this._fillMode === FILLMODE_KEEP_ASPECT) { const r = this.graphicsDevice.canvas.width / this.graphicsDevice.canvas.height; const winR = windowWidth / windowHeight; if (r > winR) { width = windowWidth; height = width / r; } else { height = windowHeight; width = height * r; } } else if (this._fillMode === FILLMODE_FILL_WINDOW) { width = windowWidth; height = windowHeight; } this.graphicsDevice.canvas.style.width = `${width}px`; this.graphicsDevice.canvas.style.height = `${height}px`; this.updateCanvasSize(); return { width, height }; } updateCanvasSize() { if (!this._allowResize || this.xr?.active) { return; } if (this._resolutionMode === RESOLUTION_AUTO) { const canvas = this.graphicsDevice.canvas; this.graphicsDevice.resizeCanvas(canvas.clientWidth, canvas.clientHeight); } } onLibrariesLoaded() { this._librariesLoaded = true; if (this.systems.rigidbody) { this.systems.rigidbody.onLibraryLoaded(); } } applySceneSettings(settings) { let asset; if (this.systems.rigidbody && typeof Ammo !== "undefined") { const [x, y, z] = settings.physics.gravity; this.systems.rigidbody.gravity.set(x, y, z); } this.scene.applySettings(settings); if (settings.render.hasOwnProperty("skybox")) { if (settings.render.skybox) { asset = this.assets.get(settings.render.skybox); if (asset) { this.setSkybox(asset); } else { this.assets.once(`add:${settings.render.skybox}`, this.setSkybox, this); } } else { this.setSkybox(null); } } } setAreaLightLuts(ltcMat1, ltcMat2) { if (ltcMat1 && ltcMat2) { AreaLightLuts.set(this.graphicsDevice, ltcMat1, ltcMat2); } else { } } setSkybox(asset) { if (asset !== this._skyboxAsset) { const onSkyboxRemoved = () => { this.setSkybox(null); }; const onSkyboxChanged = () => { this.scene.setSkybox(this._skyboxAsset ? this._skyboxAsset.resources : null); }; if (this._skyboxAsset) { this.assets.off(`load:${this._skyboxAsset.id}`, onSkyboxChanged, this); this.assets.off(`remove:${this._skyboxAsset.id}`, onSkyboxRemoved, this); this._skyboxAsset.off("change", onSkyboxChanged, this); } this._skyboxAsset = asset; if (this._skyboxAsset) { this.assets.on(`load:${this._skyboxAsset.id}`, onSkyboxChanged, this); this.assets.once(`remove:${this._skyboxAsset.id}`, onSkyboxRemoved, this); this._skyboxAsset.on("change", onSkyboxChanged, this); if (this.scene.skyboxMip === 0 && !this._skyboxAsset.loadFaces) { this._skyboxAsset.loadFaces = true; } this.assets.load(this._skyboxAsset); } onSkyboxChanged(); } } _firstBake() { this.lightmapper?.bake(null, this.scene.lightmapMode); } _firstBatch() { this.batcher?.generate(); } _processTimestamp(timestamp) { return timestamp; } drawLine(start, end, color, depthTest, layer) { this.scene.drawLine(start, end, color, depthTest, layer); } drawLines(positions, colors, depthTest = true, layer = this.scene.defaultDrawLayer) { this.scene.drawLines(positions, colors, depthTest, layer); } drawLineArrays(positions, colors, depthTest = true, layer = this.scene.defaultDrawLayer) { this.scene.drawLineArrays(positions, colors, depthTest, layer); } drawWireSphere(center, radius, color = Color.WHITE, segments = 20, depthTest = true, layer = this.scene.defaultDrawLayer) { this.scene.immediate.drawWireSphere(center, radius, color, segments, depthTest, layer); } drawWireAlignedBox(minPoint, maxPoint, color = Color.WHITE, depthTest = true, layer = this.scene.defaultDrawLayer, mat) { this.scene.immediate.drawWireAlignedBox(minPoint, maxPoint, color, depthTest, layer, mat); } drawMeshInstance(meshInstance, layer = this.scene.defaultDrawLayer) { this.scene.immediate.drawMesh(null, null, null, meshInstance, layer); } drawMesh(mesh, material, matrix, layer = this.scene.defaultDrawLayer) { this.scene.immediate.drawMesh(material, matrix, mesh, null, layer); } drawQuad(matrix, material, layer = this.scene.defaultDrawLayer) { this.scene.immediate.drawMesh(material, matrix, this.scene.immediate.getQuadMesh(), null, layer); } drawTexture(x, y, width, height, texture, material, layer = this.scene.defaultDrawLayer, filterable = true) { if (filterable === false && !this.graphicsDevice.isWebGPU) { return; } const matrix = new Mat4(); matrix.setTRS(new Vec3(x, y, 0), Quat.IDENTITY, new Vec3(width, -height, 0)); if (!material) { material = new ShaderMaterial(); material.cull = CULLFACE_NONE; material.setParameter("colorMap", texture); material.shaderDesc = filterable ? this.scene.immediate.getTextureShaderDesc(texture.encoding) : this.scene.immediate.getUnfilterableTextureShaderDesc(); material.update(); } this.drawQuad(matrix, material, layer); } drawDepthTexture(x, y, width, height, layer = this.scene.defaultDrawLayer) { const material = new ShaderMaterial(); material.cull = CULLFACE_NONE; material.shaderDesc = this.scene.immediate.getDepthTextureShaderDesc(); material.update(); this.drawTexture(x, y, width, height, null, material, layer); } destroy() { if (this._inFrameUpdate) { this._destroyRequested = true; return; } const canvasId = this.graphicsDevice.canvas.id; this.fire("destroy", this); this.off("librariesloaded"); this._gsplatSortedEvt?.off(); this._gsplatSortedEvt = null; if (typeof document !== "undefined") { document.removeEventListener("visibilitychange", this._visibilityChangeHandler, false); } this._visibilityChangeHandler = null; this.root.destroy(); this.root = null; if (this.mouse) { this.mouse.off(); this.mouse.detach(); this.mouse = null; } if (this.keyboard) { this.keyboard.off(); this.keyboard.detach(); this.keyboard = null; } if (this.touch) { this.touch.off(); this.touch.detach(); this.touch = null; } if (this.elementInput) { this.elementInput.detach(); this.elementInput = null; } if (this.gamepads) { this.gamepads.destroy(); this.gamepads = null; } this.systems.destroy(); if (this.scene.layers) { this.scene.layers.destroy(); } this.bundles.destroy(); this.bundles = null; this.i18n.destroy(); this.i18n = null; const scriptHandler = this.loader.getHandler("script"); scriptHandler?.clearCache(); this.loader.destroy(); this.loader = null; this.systems = null; this.context = null; this.scripts.destroy(); this.scripts = null; this.scenes.destroy(); this.scenes = null; this.lightmapper?.destroy(); this.lightmapper = null; if (this._batcher) { this._batcher.destroy(); this._batcher = null; } this._entityIndex = {}; this.defaultLayerDepth.onDisable = null; this.defaultLayerDepth.onEnable = null; this.defaultLayerDepth = null; this.defaultLayerWorld = null; this.xr?.end(); this.xr?.destroy(); this.renderer.destroy(); this.renderer = null; const assets = this.assets.list(); for (let i = 0; i < assets.length; i++) { assets[i].unload(); assets[i].off(); } this.assets.off(); this.scene.destroy(); this.scene = null; this.graphicsDevice.destroy(); this.graphicsDevice = null; this.off(); this._soundManager?.destroy(); this._soundManager = null; script.app = null; AppBase._applications[canvasId] = null; if (getApplication() === this) { setApplication(null); } AppBase.cancelTick(this); } static cancelTick(app2) { if (app2.frameRequestId) { cancelAnimationFrame(app2.frameRequestId); app2.frameRequestId = void 0; } } getEntityFromIndex(guid) { return this._entityIndex[guid]; } _registerSceneImmediate(scene) { this.on("postrender", scene.immediate.onPostRender, scene.immediate); this._gsplatSortedEvt = scene.on("gsplat:sorted", (sortTime) => { this.stats.frame.gsplatSort += sortTime; }); } } export { AppBase, app };