diff --git a/src/lib/helpers/logger.ts b/src/lib/helpers/logger.ts new file mode 100644 index 00000000..1b246694 --- /dev/null +++ b/src/lib/helpers/logger.ts @@ -0,0 +1,53 @@ +enum ELogLevel { + WARN = 0, + INFO, + DEBUG, + DIAGNOSTIC, +} + +type LevelStyle = { + readonly method: 'error' | 'warn' | 'log' | 'debug'; + readonly colors: { + readonly header: string; + readonly message: string; + }; +}; + +class Logger { + // eslint-disable-next-line no-useless-constructor + public constructor( + private readonly prefix: string, + private readonly logStyle: Record + ) {} + + public verbosity = ELogLevel.WARN; + + public readonly warn = this.log.bind(this, ELogLevel.WARN); + public readonly info = this.log.bind(this, ELogLevel.INFO); + public readonly debug = this.log.bind(this, ELogLevel.DEBUG); + public readonly diagnostic = this.log.bind(this, ELogLevel.DIAGNOSTIC); + + private log(level: ELogLevel, message: string): void { + if (this.verbosity >= level) { + const logStyle = this.logStyle[level]; + + console[logStyle.method]( + `%c${this.prefix}%c ${message}`, + `background: ${logStyle.colors.header}; color: white; padding: 2px 4px; border-radius: 2px`, + `font-weight: 800; color: ${logStyle.colors.message}` + ); + } + } +} + +const logger = new Logger('aresrpg-engine', [ + { method: 'warn', colors: { header: '#7B9E7B', message: '#FF6A00' } }, + { method: 'log', colors: { header: '#7B9E7B', message: '#0094FF' } }, + { method: 'debug', colors: { header: '#7B9E7B', message: '#808080' } }, + { method: 'debug', colors: { header: '#7B9E7B', message: '#A56148' } }, +]); +function setVerbosity(verbosity: ELogLevel): void { + logger.verbosity = verbosity; +} + +export { ELogLevel, logger, setVerbosity }; diff --git a/src/lib/helpers/webgpu/webgpu-device.ts b/src/lib/helpers/webgpu/webgpu-device.ts index 56c9abcd..c4fe44e7 100644 --- a/src/lib/helpers/webgpu/webgpu-device.ts +++ b/src/lib/helpers/webgpu/webgpu-device.ts @@ -1,5 +1,7 @@ /// +import { logger } from '../logger'; + let devicePromise: Promise | null = null; async function getGpuDevice(): Promise { @@ -18,7 +20,7 @@ async function getGpuDevice(): Promise { } if (adapter.isFallbackAdapter) { - console.warn('The retrieved GPU adapter is fallback. The performance might be degraded.'); + logger.warn('The retrieved GPU adapter is fallback. The performance might be degraded.'); } devicePromise = adapter.requestDevice(); diff --git a/src/lib/index.ts b/src/lib/index.ts index 40521731..2d6ce4bd 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,2 +1,3 @@ +export { ELogLevel, setVerbosity } from './helpers/logger'; export type { IVoxel, IVoxelMap, IVoxelMaterial } from './terrain/i-voxel-map'; export { EPatchComputingMode, Terrain } from './terrain/terrain'; diff --git a/src/lib/terrain/async-patch.ts b/src/lib/terrain/async-patch.ts index 5937dfae..8e1e5855 100644 --- a/src/lib/terrain/async-patch.ts +++ b/src/lib/terrain/async-patch.ts @@ -6,22 +6,29 @@ class AsyncPatch { readonly state: 'pending'; readonly promise: Promise; visible: boolean; - deleted: boolean; + disposed: boolean; } | { readonly state: 'ready'; readonly patch: Patch | null; - deleted: boolean; + disposed: boolean; }; - public constructor(container: THREE.Object3D, promise: Promise) { + public readonly id: string; + public readonly boundingBox: THREE.Box3; + private invisibilityTimestamp = performance.now(); + + public constructor(container: THREE.Object3D, promise: Promise, id: string, boundingBox: THREE.Box3) { this.data = { state: 'pending', promise, visible: false, - deleted: false, + disposed: false, }; + this.id = id; + this.boundingBox = boundingBox; + promise.then((patch: Patch | null) => { if (this.data.state !== 'pending') { throw new Error(); @@ -35,8 +42,12 @@ class AsyncPatch { this.data = { state: 'ready', patch, - deleted: this.data.deleted, + disposed: this.data.disposed, }; + if (this.data.disposed) { + // disposal has been asked before the computation ended + this.patch?.dispose(); + } }); } @@ -50,6 +61,14 @@ class AsyncPatch { } public set visible(value: boolean) { + if (this.visible === value) { + return; // nothing to do + } + + if (!value) { + this.invisibilityTimestamp = performance.now(); + } + if (this.data.state === 'pending') { this.data.visible = value; } else if (this.data.patch) { @@ -64,9 +83,13 @@ class AsyncPatch { return null; } + public get invisibleSince(): number { + return this.invisibilityTimestamp; + } + public async dispose(): Promise { - if (!this.data.deleted) { - this.data.deleted = true; + if (!this.data.disposed) { + this.data.disposed = true; this.patch?.dispose(); } } diff --git a/src/lib/terrain/patch/patch-factory/patch-factory-base.ts b/src/lib/terrain/patch/patch-factory/patch-factory-base.ts index 8bc8ef8a..7ad44dd3 100644 --- a/src/lib/terrain/patch/patch-factory/patch-factory-base.ts +++ b/src/lib/terrain/patch/patch-factory/patch-factory-base.ts @@ -29,6 +29,7 @@ type FaceData = { type LocalMapCache = { readonly data: Uint16Array; readonly size: THREE.Vector3; + readonly isEmpty: boolean; neighbourExists(voxelIndex: number, neighbourRelativePosition: THREE.Vector3): boolean; }; @@ -99,8 +100,6 @@ abstract class PatchFactoryBase { boundingBox.getBoundingSphere(boundingSphere); return new Patch( - patchStart, - patchSize, geometryAndMaterialsList.map(geometryAndMaterial => { const { geometry } = geometryAndMaterial; geometry.boundingBox = boundingBox.clone(); @@ -206,6 +205,10 @@ abstract class PatchFactoryBase { } private *iterateOnVisibleFacesWithCache(localMapCache: LocalMapCache): Generator { + if (localMapCache.isEmpty) { + return; + } + let cacheIndex = 0; const localPosition = new THREE.Vector3(); for (localPosition.z = 0; localPosition.z < localMapCache.size.z; localPosition.z++) { @@ -309,15 +312,18 @@ abstract class PatchFactoryBase { return neighbourData !== 0; }; + let isEmpty = true; for (const voxel of this.map.iterateOnVoxels(cacheStart, cacheEnd)) { const localPosition = new THREE.Vector3().subVectors(voxel.position, cacheStart); const cacheIndex = buildIndex(localPosition); cache[cacheIndex] = 1 + voxel.materialId; + isEmpty = false; } return { data: cache, size: cacheSize, + isEmpty, neighbourExists, }; } diff --git a/src/lib/terrain/patch/patch-factory/split/gpu/patch-computer-gpu.ts b/src/lib/terrain/patch/patch-factory/split/gpu/patch-computer-gpu.ts index 6b775a4a..5b128963 100644 --- a/src/lib/terrain/patch/patch-factory/split/gpu/patch-computer-gpu.ts +++ b/src/lib/terrain/patch/patch-factory/split/gpu/patch-computer-gpu.ts @@ -2,6 +2,7 @@ import * as THREE from 'three'; +import { logger } from '../../../../../helpers/logger'; import { getGpuDevice } from '../../../../../helpers/webgpu/webgpu-device'; import * as Cube from '../../cube'; import { type LocalMapCache } from '../../patch-factory-base'; @@ -16,6 +17,7 @@ type ComputationOutputs = Record; class PatchComputerGpu { public static async create(localCacheSize: THREE.Vector3, vertexDataEncoder: VertexDataEncoder): Promise { + logger.debug('Requesting WebGPU device...'); const device = await getGpuDevice(); return new PatchComputerGpu(device, localCacheSize, vertexDataEncoder); } @@ -205,7 +207,7 @@ class PatchComputerGpu { Object.values(this.faceBuffers).forEach( faceBuffer => (totalBuffersSize += faceBuffer.readableBuffer.size + faceBuffer.storageBuffer.size) ); - console.log(`Allocated ${(totalBuffersSize / 1024 / 1024).toFixed(1)} MB of webgpu buffers.`); + logger.info(`Allocated ${(totalBuffersSize / 1024 / 1024).toFixed(1)} MB of webgpu buffers.`); const bindgroupBuffers = [this.localCacheBuffer, ...Object.values(this.faceBuffers).map(faceBuffer => faceBuffer.storageBuffer)]; this.computePipelineBindgroup = this.device.createBindGroup({ @@ -280,10 +282,13 @@ class PatchComputerGpu { } public dispose(): void { + this.localCacheBuffer.destroy(); for (const buffer of Object.values(this.faceBuffers)) { buffer.storageBuffer.destroy(); buffer.readableBuffer.destroy(); } + logger.debug('Destroying WebGPU device...'); + this.device.destroy(); } } diff --git a/src/lib/terrain/patch/patch-factory/split/gpu/patch-factory-gpu-optimized.ts b/src/lib/terrain/patch/patch-factory/split/gpu/patch-factory-gpu-optimized.ts index b4c1e88a..ae1a317e 100644 --- a/src/lib/terrain/patch/patch-factory/split/gpu/patch-factory-gpu-optimized.ts +++ b/src/lib/terrain/patch/patch-factory/split/gpu/patch-factory-gpu-optimized.ts @@ -30,14 +30,14 @@ class PatchFactoryGpuOptimized extends PatchFactoryGpu { return new Promise(resolve => { const patchId = this.nextPatchId++; - // console.log(`Asking for patch ${patchId}`); + // logger.diagnostic(`Asking for patch ${patchId}`); this.pendingJobs.push({ patchId, cpuTask: () => { - // console.log(`CPU ${patchId} start`); + // logger.diagnostic(`CPU ${patchId} start`); const result = this.buildLocalMapCache(patchStart, patchEnd); - // console.log(`CPU ${patchId} end`); + // logger.diagnostic(`CPU ${patchId} end`); return result; }, resolve, @@ -58,17 +58,24 @@ class PatchFactoryGpuOptimized extends PatchFactoryGpu { if (!currentJob.gpuTaskPromise) { const localMapCache = currentJob.cpuTaskOutput; - currentJob.gpuTaskPromise = (async () => { - // console.log(`GPU ${currentJob.patchId} start`); - const patchComputerGpu = await this.getPatchComputerGpu(); - const gpuTaskOutput = await patchComputerGpu.computeBuffers(localMapCache); - // console.log(`GPU ${currentJob.patchId} end`); - - const result = this.assembleGeometryAndMaterials(gpuTaskOutput); - currentJob.resolve(result); + if (localMapCache.isEmpty) { + currentJob.gpuTaskPromise = Promise.resolve(); this.pendingJobs.shift(); - this.runNextTask(); - })(); + currentJob.resolve([]); + setTimeout(() => this.runNextTask()); + } else { + currentJob.gpuTaskPromise = (async () => { + // logger.diagnostic(`GPU ${currentJob.patchId} start`); + const patchComputerGpu = await this.getPatchComputerGpu(); + const gpuTaskOutput = await patchComputerGpu.computeBuffers(localMapCache); + // logger.diagnostic(`GPU ${currentJob.patchId} end`); + + const result = this.assembleGeometryAndMaterials(gpuTaskOutput); + this.pendingJobs.shift(); + currentJob.resolve(result); + this.runNextTask(); + })(); + } } const nextJob = this.pendingJobs[1]; diff --git a/src/lib/terrain/patch/patch-factory/split/gpu/patch-factory-gpu-sequential.ts b/src/lib/terrain/patch/patch-factory/split/gpu/patch-factory-gpu-sequential.ts index 0b825932..80804d71 100644 --- a/src/lib/terrain/patch/patch-factory/split/gpu/patch-factory-gpu-sequential.ts +++ b/src/lib/terrain/patch/patch-factory/split/gpu/patch-factory-gpu-sequential.ts @@ -21,6 +21,9 @@ class PatchFactoryGpuSequential extends PatchFactoryGpu { } const localMapCache = this.buildLocalMapCache(patchStart, patchEnd); + if (localMapCache.isEmpty) { + return []; + } const patchComputerGpu = await this.getPatchComputerGpu(); const buffers = await patchComputerGpu.computeBuffers(localMapCache); diff --git a/src/lib/terrain/patch/patch.ts b/src/lib/terrain/patch/patch.ts index 0eeadd34..c439fcf1 100644 --- a/src/lib/terrain/patch/patch.ts +++ b/src/lib/terrain/patch/patch.ts @@ -31,16 +31,11 @@ class Patch { }, }; - public readonly patchStart: THREE.Vector3; - public readonly patchSize: THREE.Vector3; - private gpuResources: { readonly patchMeshes: ReadonlyArray; } | null = null; - public constructor(patchStart: THREE.Vector3, patchSize: THREE.Vector3, patchMeshes: PatchMesh[]) { - this.patchStart = patchStart; - this.patchSize = patchSize; + public constructor(patchMeshes: PatchMesh[]) { this.gpuResources = { patchMeshes }; this.container = new THREE.Object3D(); diff --git a/src/lib/terrain/terrain.ts b/src/lib/terrain/terrain.ts index 5881b019..6bc5249a 100644 --- a/src/lib/terrain/terrain.ts +++ b/src/lib/terrain/terrain.ts @@ -1,3 +1,4 @@ +import { logger } from '../helpers/logger'; import * as THREE from '../three-usage'; import { AsyncPatch } from './async-patch'; @@ -42,11 +43,13 @@ class Terrain { }, }; + private maxPatchesInCache = 200; + private readonly map: IVoxelMap; private readonly patchFactory: PatchFactoryBase; private readonly patchSize: THREE.Vector3; - private patches: Record = {}; + private readonly patches: Record = {}; /** * @@ -73,7 +76,7 @@ class Terrain { } this.patchSize = this.patchFactory.maxPatchSize.clone(); - console.log(`Using max patch size ${this.patchSize.x}x${this.patchSize.y}x${this.patchSize.z}.`); + logger.info(`Using max patch size ${this.patchSize.x}x${this.patchSize.y}x${this.patchSize.z}.`); this.container = new THREE.Group(); } @@ -104,8 +107,8 @@ class Terrain { * @param radius The visibility radius, in voxels. */ public async showMapAroundPosition(position: THREE.Vector3, radius: number): Promise { - const voxelFrom = new THREE.Vector3().copy(position).subScalar(radius); - const voxelTo = new THREE.Vector3().copy(position).addScalar(radius); + const voxelFrom = new THREE.Vector3().copy(position).subScalar(radius).max({ x: 0, y: 0, z: 0 }); + const voxelTo = new THREE.Vector3().copy(position).addScalar(radius).min(this.map.size); const patchIdFrom = voxelFrom.divide(this.patchSize).floor(); const patchIdTo = voxelTo.divide(this.patchSize).ceil(); @@ -113,23 +116,32 @@ class Terrain { patch.visible = false; } + const visibilitySphere = new THREE.Sphere(position, radius); const promises: Promise[] = []; const patchId = new THREE.Vector3(); for (patchId.x = patchIdFrom.x; patchId.x < patchIdTo.x; patchId.x++) { for (patchId.y = patchIdFrom.y; patchId.y < patchIdTo.y; patchId.y++) { for (patchId.z = patchIdFrom.z; patchId.z < patchIdTo.z; patchId.z++) { const patchStart = new THREE.Vector3().multiplyVectors(patchId, this.patchSize); - const patch = this.getPatch(patchStart); - patch.visible = true; - promises.push(patch.ready()); + + const boundingBox = new THREE.Box3(patchStart, patchStart.clone().add(this.patchSize)); + if (visibilitySphere.intersectsBox(boundingBox)) { + const patch = this.getPatch(patchStart); + patch.visible = true; + promises.push(patch.ready()); + } } } } + this.garbageCollectPatches(); + await Promise.all(promises); } - /** Call this method before rendering. */ + /** + * Call this method before rendering. + * */ public updateUniforms(): void { for (const asyncPatch of Object.values(this.patches)) { const patch = asyncPatch.patch; @@ -157,11 +169,10 @@ class Terrain { * It will be recomputed if needed again. */ public clear(): void { - for (const patch of Object.values(this.patches)) { - patch.dispose(); + for (const patchId of Object.keys(this.patches)) { + this.disposePatch(patchId); } this.container.clear(); - this.patches = {}; } /** @@ -172,6 +183,48 @@ class Terrain { this.patchFactory.dispose(); } + /** + * Gets the maximum size of the GPU LRU cache of invisible patches. + */ + public get patchesCacheSize(): number { + return this.maxPatchesInCache; + } + + /** + * Sets the maximum size of the GPU LRU cache of invisible patches. + */ + public set patchesCacheSize(value: number) { + if (value <= 0) { + throw new Error(`Invalid patches cache size "${value}".`); + } + this.maxPatchesInCache = value; + this.garbageCollectPatches(); + } + + private garbageCollectPatches(): void { + const patches = Object.entries(this.patches); + const invisiblePatches = patches.filter(([, patch]) => !patch.visible); + invisiblePatches.sort(([, patch1], [, patch2]) => patch1.invisibleSince - patch2.invisibleSince); + + while (invisiblePatches.length > this.maxPatchesInCache) { + const nextPatchToDelete = invisiblePatches.shift(); + if (!nextPatchToDelete) { + break; + } + this.disposePatch(nextPatchToDelete[0]); + } + } + + private disposePatch(patchId: string): void { + const patch = this.patches[patchId]; + if (patch) { + patch.dispose(); + delete this.patches[patchId]; + } else { + logger.warn(`Patch ${patchId} does not exist.`); + } + } + private getPatch(patchStart: THREE.Vector3): AsyncPatch { const patchId = this.computePatchId(patchStart); @@ -179,8 +232,9 @@ class Terrain { if (typeof patch === 'undefined') { const patchEnd = new THREE.Vector3().addVectors(patchStart, this.patchSize); + const boundingBox = new THREE.Box3(patchStart.clone(), patchEnd.clone()); const promise = this.patchFactory.buildPatch(patchStart, patchEnd); - patch = new AsyncPatch(this.container, promise); + patch = new AsyncPatch(this.container, promise, patchId, boundingBox); this.patches[patchId] = patch; } diff --git a/src/test/main.ts b/src/test/main.ts index 19a39ef6..ee149b31 100644 --- a/src/test/main.ts +++ b/src/test/main.ts @@ -1,10 +1,17 @@ import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; +import Stats from 'three/examples/jsm/libs/stats.module.js'; -import { Terrain } from '../lib/index'; +import { EVerbosity, Terrain, setVerbosity } from '../lib/index'; import { VoxelMap } from './voxel-map'; +setVerbosity(EVerbosity.DIAGNOSTIC); + +const stats = new Stats(); +document.body.appendChild(stats.dom); + const renderer = new THREE.WebGLRenderer(); document.body.appendChild(renderer.domElement); renderer.setClearColor(0x880000); @@ -22,7 +29,7 @@ udpateRendererSize(); const scene = new THREE.Scene(); -const voxelMap = new VoxelMap(256, 256, 16); +const voxelMap = new VoxelMap(512, 512, 16); const terrain = new Terrain(voxelMap); scene.add(terrain.container); @@ -32,8 +39,33 @@ camera.position.set(-50, 100, -50); const cameraControl = new OrbitControls(camera, renderer.domElement); cameraControl.target.set(voxelMap.size.x / 2, 0, voxelMap.size.z / 2); -terrain.showEntireMap(); +const playerViewRadius = 40; +const playerContainer = new THREE.Group(); +playerContainer.position.x = voxelMap.size.x / 2; +playerContainer.position.y = voxelMap.size.y + 1; +playerContainer.position.z = voxelMap.size.z / 2; +const player = new THREE.Mesh(new THREE.SphereGeometry(2), new THREE.MeshBasicMaterial({ color: '#FF0000' })); +const playerViewSphere = new THREE.Mesh( + new THREE.SphereGeometry(playerViewRadius, 16, 16), + new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true }) +); +playerContainer.add(player); +playerContainer.add(playerViewSphere); +const playerControls = new TransformControls(camera, renderer.domElement); +playerControls.addEventListener('dragging-changed', event => { + cameraControl.enabled = !event.value; +}); +playerControls.attach(playerContainer); +scene.add(playerContainer); +scene.add(playerControls); +setInterval(() => { + terrain.showMapAroundPosition(playerContainer.position, playerViewRadius); +}, 200); + +// terrain.showEntireMap(); function render(): void { + stats.update(); + cameraControl.update(); terrain.updateUniforms(); renderer.render(scene, camera);