UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

311 lines (263 loc) 10.5 kB
/* * Copyright (c) 2015-2018, IGN France. * Copyright (c) 2018-2026, Giro3D team. * SPDX-License-Identifier: MIT */ import { Clock, MathUtils, Plane, Sphere, Vector3 } from 'three'; import type Entity from '../entities/Entity'; import type View from '../renderer/View'; import type Context from './Context'; import type Instance from './Instance'; import { isEntity3D } from '../entities/Entity3D'; import { isBufferGeometry } from '../utils/predicates'; /** Rendering state */ export enum RenderingState { /* Paused */ RENDERING_PAUSED = 0, /* Scheduled */ RENDERING_SCHEDULED = 1, } class ContextImpl implements Context { public readonly view: View; public readonly distance: { plane: Plane; min: number; max: number; }; public constructor(view: View) { this.view = view; this.distance = { plane: new Plane().setFromNormalAndCoplanarPoint( view.camera.getWorldDirection(new Vector3()), view.camera.position, ), min: Infinity, max: 0, }; } } const tmpSphere = new Sphere(); function updateElements(context: Context, entity: Entity, elements?: unknown[] | null): void { if (!elements) { return; } for (const element of elements) { // update element const newElementsToUpdate = entity.update(context, element); updateElements(context, entity, newElementsToUpdate); } } class MainLoop { private _renderingState: RenderingState; public get renderingState(): RenderingState { return this._renderingState; } private _needsRedraw: boolean; private _automaticCameraPlaneComputation = true; private _updateLoopRestarted: boolean; private readonly _changeSources: Set<unknown>; private readonly _clock = new Clock(); private _frame = 0; /** * The number of frames processed. */ public get frameCount(): number { return this._frame; } /** * Toggles automatic camera clipping plane computation. * @defaultValue true */ public get automaticCameraPlaneComputation(): boolean { return this._automaticCameraPlaneComputation; } public set automaticCameraPlaneComputation(v: boolean) { this._automaticCameraPlaneComputation = v; } public constructor() { this._renderingState = RenderingState.RENDERING_PAUSED; this._needsRedraw = false; this._updateLoopRestarted = true; this._changeSources = new Set<unknown>(); } public scheduleUpdate( instance: Instance, changeSource: unknown | unknown[] = undefined, options?: { needsRedraw?: boolean; immediate?: boolean; }, ): void { if (changeSource != null) { if (Array.isArray(changeSource)) { changeSource.forEach(s => this._changeSources.add(s)); } else { this._changeSources.add(changeSource); } } const needsRedraw = options?.needsRedraw ?? true; const immediate = options?.immediate ?? false; this._needsRedraw = this._needsRedraw || needsRedraw; if (this._renderingState !== RenderingState.RENDERING_SCHEDULED) { this._renderingState = RenderingState.RENDERING_SCHEDULED; if (immediate) { this.step(instance); } else { requestAnimationFrame(() => { this.step(instance); }); } } } private update(instance: Instance, updateSources: Set<unknown>, dt: number): void { const frame = this._frame; const context = new ContextImpl(instance.view); for (const entity of instance.getEntities()) { if (entity.shouldCheckForUpdate()) { instance.dispatchEvent({ type: 'before-entity-update', frame, entity, dt, updateLoopRestarted: this._updateLoopRestarted, }); // Filter updateSources that are relevant for the entity const srcs = entity.filterChangeSources(updateSources); if (srcs.size > 0) { // `preUpdate` returns an array of elements to update const elementsToUpdate = entity.preUpdate(context, srcs); // `update` is called in `updateElements`. updateElements(context, entity, elementsToUpdate); // `postUpdate` is called when this geom layer update process is finished entity.postUpdate(context, updateSources); } if (isEntity3D(entity)) { const entityDistance = entity.distance; context.distance.min = Math.min(context.distance.min, entityDistance.min); if (entityDistance.max === Infinity) { context.distance.max = instance.view.maxFarPlane; } else { context.distance.max = Math.max(context.distance.max, entityDistance.max); } } instance.dispatchEvent({ type: 'after-entity-update', frame, entity, dt, updateLoopRestarted: this._updateLoopRestarted, }); } } // TODO document the fact Object3D must be added through threeObjects // if they want to influence the near / far planes this.updateCameraPlanesFromObjects(context, instance); if (this.automaticCameraPlaneComputation) { instance.view.near = Number.isFinite(context.distance.min) ? context.distance.min : instance.view.near; instance.view.far = Number.isFinite(context.distance.max) ? context.distance.max : instance.view.far; instance.view.camera.updateProjectionMatrix(); } } private updateCameraPlanesFromObjects(context: Context, instance: Instance): void { instance.threeObjects.traverse(o => { if (!o.visible) { return; } if ('geometry' in o && isBufferGeometry(o.geometry)) { const boundingSphere = o.geometry.boundingSphere; if (boundingSphere && !boundingSphere.isEmpty()) { tmpSphere.copy(boundingSphere); tmpSphere.applyMatrix4(o.matrixWorld); const d = tmpSphere.distanceToPoint(context.view.camera.position); context.distance.min = MathUtils.clamp(d, 0.01, context.distance.min); context.distance.max = Math.max(context.distance.max, d + 2 * tmpSphere.radius); } } }); } private step(instance: Instance): void { const dt = this._clock.getDelta() * 1000; const frame = this._frame++; instance.dispatchEvent({ type: 'update-start', frame, dt, updateLoopRestarted: this._updateLoopRestarted, }); const willRedraw = this._needsRedraw; // Reset internal state before calling _update (so future calls to Instance.notifyChange() // can properly change it) this._needsRedraw = false; this._renderingState = RenderingState.RENDERING_PAUSED; const updateSources = new Set(this._changeSources); this._changeSources.clear(); instance.dispatchEvent({ type: 'before-camera-update', frame, camera: instance.view, dt, updateLoopRestarted: this._updateLoopRestarted, }); if (this.automaticCameraPlaneComputation) { // Reset near/far to default value to allow update function to test // visibility using camera's frustum; without depending on the near/far // values which are only used for rendering. instance.view.resetPlanes(); } const dim = instance.engine.getWindowSize(); instance.view.setSize(dim.x, dim.y); instance.view.update(); instance.dispatchEvent({ type: 'after-camera-update', frame, camera: instance.view, dt, updateLoopRestarted: this._updateLoopRestarted, }); // Disable camera's matrix auto update to make sure the camera's // world matrix is never updated mid-update. // Otherwise inconsistencies can appear because object visibility // testing and object drawing could be performed using different // camera matrixWorld. // Note: this is required at least because WEBGLRenderer calls // camera.updateMatrixWorld() const oldAutoUpdate = instance.view.camera.matrixAutoUpdate; instance.view.camera.matrixAutoUpdate = false; // update data-structure this.update(instance, updateSources, dt); // Redraw *only* if needed. // (redraws only happen when this.needsRedraw is true, which in turn only happens when // instance.notifyChange() is called with redraw=true) // As such there's no continuous update-loop, instead we use a ad-hoc update/render // mechanism. if (willRedraw) { instance.dispatchEvent({ type: 'before-render', frame, dt, updateLoopRestarted: this._updateLoopRestarted, }); instance.render(); instance.dispatchEvent({ type: 'after-render', frame, dt, updateLoopRestarted: this._updateLoopRestarted, }); } // next time, we'll consider that we've just started the loop if we are still PAUSED now this._updateLoopRestarted = this._renderingState === RenderingState.RENDERING_PAUSED; instance.view.camera.matrixAutoUpdate = oldAutoUpdate; instance.dispatchEvent({ type: 'update-end', frame, dt, updateLoopRestarted: this._updateLoopRestarted, }); } } export default MainLoop;