import { EVENT_POSTRENDER_LAYER, EVENT_PRERENDER_LAYER, LAYERID_DEPTH, SORTMODE_CUSTOM, BoundingBox, CameraComponent, Color, Entity, Layer, GraphicsDevice, MeshInstance, Vec3 } from 'playcanvas'; import { AssetLoader } from './asset-loader'; import { Camera } from './camera'; import { CameraPoseGizmos } from './camera-pose-gizmos'; import { CommandQueue } from './command-queue'; import { DataProcessor } from './data-processor'; import { Element, ElementType, ElementTypeList } from './element'; import { Events } from './events'; import { InfiniteGrid as Grid } from './infinite-grid'; import { Outline } from './outline'; import { PCApp } from './pc-app'; import { SceneConfig } from './scene-config'; import { SceneState } from './scene-state'; import { Splat } from './splat'; import { SplatOverlay } from './splat-overlay'; import { Underlay } from './underlay'; // sort meshInstances by the aabb corner furthest from the camera const corner = new Vec3(); const specialSort = (instances: MeshInstance[], numInstances: number, cameraPos: Vec3, cameraDir: Vec3) => { const distances = new Map(); for (let i = 0; i < numInstances; i++) { const instance = instances[i]; const { aabb } = instance; const { center, halfExtents } = aabb; // loop over all 8 aabb corners and find the furthest distance along the camera view direction let maxDist = -Infinity; for (let cx = -1; cx <= 1; cx += 2) { for (let cy = -1; cy <= 1; cy += 2) { for (let cz = -1; cz <= 1; cz += 2) { corner.set( center.x + cx * halfExtents.x, center.y + cy * halfExtents.y, center.z + cz * halfExtents.z ); // project camera-to-corner vector onto camera direction const dist = (corner.x - cameraPos.x) * cameraDir.x + (corner.y - cameraPos.y) * cameraDir.y + (corner.z - cameraPos.z) * cameraDir.z; if (dist > maxDist) { maxDist = dist; } } } } // store in map for reuse during sort distances.set(instance, maxDist); } // sort instances back-to-front by calculated distance (furthest first) instances.sort((a, b) => distances.get(b) - distances.get(a)); }; class Scene { events: Events; config: SceneConfig; canvas: HTMLCanvasElement; app: PCApp; worldLayer: Layer; splatLayer: Layer; gizmoLayer: Layer; sceneState = [new SceneState(), new SceneState()]; elements: Element[] = []; boundStorage = new BoundingBox(); boundDirty = true; forceRender = false; lockedRenderMode = false; lockedRender = false; canvasResize: {width: number; height: number} | null = null; targetSize = { width: 0, height: 0 }; dataProcessor: DataProcessor; assetLoader: AssetLoader; camera: Camera; cameraPoseGizmos: CameraPoseGizmos; splatOverlay: SplatOverlay; grid: Grid; outline: Outline; underlay: Underlay; // shared queue for serialising async splat work. exposed so subsystems that // need to order their async work alongside edit-history operations can do so // without going through edit-history directly. commandQueue: CommandQueue; contentRoot: Entity; cameraRoot: Entity; constructor( events: Events, config: SceneConfig, canvas: HTMLCanvasElement, graphicsDevice: GraphicsDevice, commandQueue: CommandQueue ) { this.events = events; this.config = config; this.canvas = canvas; this.commandQueue = commandQueue; // configure the playcanvas application. we render to an offscreen buffer so require // only the simplest of backbuffers. this.app = new PCApp(canvas, { graphicsDevice }); // only render the scene when instructed this.app.autoRender = false; // @ts-ignore this.app._allowResize = false; this.app.scene.clusteredLightingEnabled = false; // hack: disable lightmapper first bake until we expose option for this // @ts-ignore this.app.off('prerender', this.app._firstBake, this.app); // @ts-ignore this.app.loader.getHandler('texture').imgParser.crossOrigin = 'anonymous'; // this is required to get full res AR mode backbuffer this.app.graphicsDevice.maxPixelRatio = window.devicePixelRatio; // configure application canvas const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => { if (entries.length > 0) { const entry = entries[0]; if (entry) { if (entry.devicePixelContentBoxSize) { // on non-safari browsers, we are given the pixel-perfect canvas size this.canvasResize = { width: entry.devicePixelContentBoxSize[0].inlineSize, height: entry.devicePixelContentBoxSize[0].blockSize }; } else if (entry.contentBoxSize.length > 0) { // on safari browsers we must calculate pixel size from CSS size ourselves // and hope the browser performs the same calculation. const pixelRatio = window.devicePixelRatio; this.canvasResize = { width: Math.ceil(entry.contentBoxSize[0].inlineSize * pixelRatio), height: Math.ceil(entry.contentBoxSize[0].blockSize * pixelRatio) }; } } this.forceRender = true; } }); observer.observe(window.document.getElementById('canvas-container')); // configure depth layers to handle dynamic refraction const depthLayer = this.app.scene.layers.getLayerById(LAYERID_DEPTH); this.app.scene.layers.remove(depthLayer); this.app.scene.layers.insertOpaque(depthLayer, 2); // register application callbacks this.app.on('update', (deltaTime: number) => this.onUpdate(deltaTime)); this.app.on('prerender', () => this.onPreRender()); this.app.on('postrender', () => this.onPostRender()); // force render on device restored this.app.graphicsDevice.on('devicerestored', () => { this.forceRender = true; }); // fire pre and post render events on the camera this.app.scene.on(EVENT_PRERENDER_LAYER, (camera: CameraComponent, layer: Layer, transparent: boolean) => { camera.fire('preRenderLayer', layer, transparent); }); this.app.scene.on(EVENT_POSTRENDER_LAYER, (camera: CameraComponent, layer: Layer, transparent: boolean) => { camera.fire('postRenderLayer', layer, transparent); }); // get the world layer this.worldLayer = this.app.scene.layers.getLayerByName('World'); // splat layer - dedicated layer for splat rendering with MRT this.splatLayer = new Layer({ name: 'Splat', opaqueSortMode: SORTMODE_CUSTOM, transparentSortMode: SORTMODE_CUSTOM }); this.splatLayer.customCalculateSortValues = specialSort; // gizmo layer this.gizmoLayer = new Layer({ name: 'Gizmo' }); const layers = this.app.scene.layers; layers.push(this.splatLayer); layers.push(this.gizmoLayer); this.dataProcessor = new DataProcessor(this.app.graphicsDevice); this.assetLoader = new AssetLoader(this.app, events); // create root entities this.contentRoot = new Entity('contentRoot'); this.app.root.addChild(this.contentRoot); this.cameraRoot = new Entity('cameraRoot'); this.app.root.addChild(this.cameraRoot); // create elements this.camera = new Camera(); this.add(this.camera); this.cameraPoseGizmos = new CameraPoseGizmos(); this.add(this.cameraPoseGizmos); this.splatOverlay = new SplatOverlay(); this.add(this.splatOverlay); this.grid = new Grid(); this.add(this.grid); this.outline = new Outline(); this.add(this.outline); this.underlay = new Underlay(); this.add(this.underlay); } start() { // start the app this.app.start(); } clear() { const splats = this.getElementsByType(ElementType.splat); splats.forEach((splat) => { this.remove(splat); (splat as Splat).destroy(); }); } // add a scene element async add(element: Element) { if (!element.scene) { // add the new element element.scene = this; await element.add(); this.elements.push(element); // notify all elements of scene addition this.forEachElement(e => e !== element && e.onAdded(element)); // notify listeners this.events.fire('scene.elementAdded', element); } } // remove an element from the scene remove(element: Element) { if (element.scene === this) { // remove from list this.elements.splice(this.elements.indexOf(element), 1); // notify listeners this.events.fire('scene.elementRemoved', element); // notify all elements of scene removal this.forEachElement(e => e.onRemoved(element)); element.remove(); element.scene = null; } } // get the scene bound get bound() { if (this.boundDirty) { let valid = false; this.forEachElement((e) => { const bound = e.worldBound; if (bound) { if (!valid) { valid = true; this.boundStorage.copy(bound); } else { this.boundStorage.add(bound); } } }); this.boundDirty = false; this.events.fire('scene.boundChanged', this.boundStorage); } return this.boundStorage; } getElementsByType(elementType: ElementType) { return this.elements.filter(e => e.type === elementType); } get graphicsDevice() { return this.app.graphicsDevice; } private forEachElement(action: (e: Element) => void) { this.elements.forEach(action); } private onUpdate(deltaTime: number) { // allow elements to update this.forEachElement(e => e.onUpdate(deltaTime)); // fire global update this.events.fire('update', deltaTime); // fire a 'serialize' event which listers will use to store their state. we'll use // this to decide if the view has changed and so requires rendering. const i = this.app.frame % 2; const state = this.sceneState[i]; state.reset(); this.forEachElement(e => state.pack(e)); // diff with previous state const result = state.compare(this.sceneState[1 - i]); // generate the set of all element types that changed const all = new Set([...result.added, ...result.removed, ...result.moved, ...result.changed]); // compare with previously serialized if (this.lockedRenderMode) { this.app.renderNextFrame = this.lockedRender; this.lockedRender = false; } else if (!this.app.renderNextFrame) { this.app.renderNextFrame = this.forceRender || all.size > 0; } this.forceRender = false; // raise per-type update events ElementTypeList.forEach((type) => { if (all.has(type)) { this.events.fire(`updated:${type}`); } }); // allow elements to postupdate this.forEachElement(e => e.onPostUpdate()); } private onPreRender() { if (this.canvasResize) { this.canvas.width = this.canvasResize.width; this.canvas.height = this.canvasResize.height; this.canvasResize = null; } // update render target size this.targetSize.width = Math.ceil(this.app.graphicsDevice.width / this.config.camera.pixelScale); this.targetSize.height = Math.ceil(this.app.graphicsDevice.height / this.config.camera.pixelScale); this.forEachElement(e => e.onPreRender()); this.events.fire('prerender', this.camera.displayTransform); // debug - display scene bound if (this.config.debug.showBound) { // draw element bounds this.forEachElement((e: Element) => { if (e.type === ElementType.splat) { const splat = e as Splat; const local = splat.localBound; this.app.drawWireAlignedBox( local.getMin(), local.getMax(), Color.RED, true, undefined, splat.entity.getWorldTransform()); const world = splat.worldBound; this.app.drawWireAlignedBox( world.getMin(), world.getMax(), Color.GREEN); } }); // draw scene bound this.app.drawWireAlignedBox(this.bound.getMin(), this.bound.getMax(), Color.BLUE); } } private onPostRender() { this.forEachElement(e => e.onPostRender()); this.events.fire('postrender'); } } export { SceneConfig, Scene };