diff --git a/src/client/game.ts b/src/client/game.ts index 587d171..4578841 100644 --- a/src/client/game.ts +++ b/src/client/game.ts @@ -96,7 +96,7 @@ export class Game { this.thirdPersonCamera?.update(dt); this.joystick?.update(dt); - this.world?.update(dt); + this.player && this.world?.update(this.player.container.position); } bindSocket() { diff --git a/src/client/object/world/ClientWorld.ts b/src/client/object/world/ClientWorld.ts index 09ed558..bc572c2 100644 --- a/src/client/object/world/ClientWorld.ts +++ b/src/client/object/world/ClientWorld.ts @@ -1,19 +1,17 @@ -import { Mesh, Object3D, Vector3 } from 'three'; +import { Object3D, Vector2, Vector3 } from 'three'; import { WorldChunk } from '../../../common/world/WorldChunk'; import { WorldManager } from '../../../common/world/WorldManager'; import { ClientWorldChunkShader } from './ClientWorldChunkShader'; -import { ClientWorldMesher } from './ClientWorldMesher'; import { ClientWorldTexture } from './ClientWorldTexture'; +import { QuadtreeMesher } from './quadtree/quadtree-mesher'; // TODO: distance loading -// TODO: LOD const BASE = '/assets/terrain/'; export class ClientWorld extends WorldManager { public world = new Object3D(); - private _mesher = new ClientWorldMesher(); - private _chunkMeshes: Mesh[] = []; + private _chunkMeshers: QuadtreeMesher[] = []; private _chunkMeshQueue: WorldChunk[] = []; private _worldTextures: Map = new Map(); private _shader = new ClientWorldChunkShader(this._worldTextures); @@ -62,7 +60,7 @@ export class ClientWorld extends WorldManager { } } - update(dt: number) { + update(camera: Vector3) { if (this._chunkMeshQueue.length) { const chunk = this._chunkMeshQueue.shift(); const material = this._shader.getShader( @@ -71,16 +69,22 @@ export class ClientWorld extends WorldManager { chunk.region.splat.map((file) => `${BASE}/texture/${file}`), ); - const mesh = this._mesher.createTerrainMesh( - chunk, + const root = new QuadtreeMesher( + chunk.size, + new Vector2(chunk.size * chunk.x, chunk.size * chunk.y), material, - this.getHeight.bind(this), - this.getNormalVector.bind(this), ); - this._chunkMeshes.push(mesh); - this.world.add(mesh); + root.getHeight = this.getInterpolatedHeight.bind(this); + root.getNormal = this.getNormalVector.bind(this); + root.initialize(); + + this._chunkMeshers.push(root); + this.world.add(root.container); + return; } + + this._chunkMeshers.forEach((item) => item.update(camera)); } private createMeshes() { diff --git a/src/client/object/world/ClientWorldMesher.ts b/src/client/object/world/ClientWorldMesher.ts deleted file mode 100644 index 0bdd55b..0000000 --- a/src/client/object/world/ClientWorldMesher.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - BufferGeometry, - Float32BufferAttribute, - Material, - Mesh, - Vector3, -} from 'three'; -import { WorldChunk } from '../../../common/world/WorldChunk'; - -export class ClientWorldMesher { - public createGeometry( - chunk: WorldChunk, - getHeight: (x: number, y: number) => number, - getNormal: (x: number, y: number) => Vector3, - ): BufferGeometry { - const geometry = new BufferGeometry(); - const vertices = []; - const normals = []; - const indices = []; - const uvs = []; - for (let x = 0; x < chunk.size; x++) { - for (let y = 0; y < chunk.size; y++) { - const normal = getNormal(y, x); - vertices.push( - (y / chunk.size - 1) * chunk.size, - getHeight(y, x), - (x / chunk.size - 1) * chunk.size, - ); - normals.push(normal.x, normal.y, normal.z); - uvs.push(y / (chunk.size - 1), x / (chunk.size - 1)); - } - } - - for (let x = 0; x < chunk.size - 1; x++) { - for (let y = 0; y < chunk.size - 1; y++) { - const topLeft = x * chunk.size + y; - const topRight = topLeft + 1; - const bottomLeft = (x + 1) * chunk.size + y; - const bottomRight = bottomLeft + 1; - indices.push( - topLeft, - bottomLeft, - topRight, - topRight, - bottomLeft, - bottomRight, - ); - } - } - - geometry.setIndex(indices); - geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3)); - geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3)); - geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)); - - return geometry; - } - - public createTerrainMesh( - chunk: WorldChunk, - material: Material, - getHeight: (x: number, y: number) => number, - getNormal: (x: number, y: number) => Vector3, - ): Mesh { - const geometry = this.createGeometry(chunk, getHeight, getNormal); - const mesh = new Mesh(geometry, material); - mesh.position.set( - chunk.size * (chunk.x + 1), - 0, - chunk.size * (chunk.y + 1), - ); - - return mesh; - } -} diff --git a/src/client/object/world/quadtree/quadtree-mesher.ts b/src/client/object/world/quadtree/quadtree-mesher.ts new file mode 100644 index 0000000..7283315 --- /dev/null +++ b/src/client/object/world/quadtree/quadtree-mesher.ts @@ -0,0 +1,26 @@ +import { Material, Object3D, Vector2, Vector3 } from 'three'; +import { QuadtreeNode } from './quadtree-node'; + +export class QuadtreeMesher { + public getHeight!: (x: number, y: number) => number; + public getNormal!: (x: number, y: number) => Vector3; + public root!: QuadtreeNode; + public container = new Object3D(); + public actionsLeft = 1; + public maxDepth = 3; + + constructor( + public size: number, + public position: Vector2, + public material: Material, + ) {} + + public update(camera: Vector3) { + this.actionsLeft = 1; + this.root?.update(camera); + } + + public initialize() { + this.root = new QuadtreeNode(this, null, 0, 0, this.position.clone()); + } +} diff --git a/src/client/object/world/quadtree/quadtree-node.ts b/src/client/object/world/quadtree/quadtree-node.ts new file mode 100644 index 0000000..120976b --- /dev/null +++ b/src/client/object/world/quadtree/quadtree-node.ts @@ -0,0 +1,286 @@ +import { + BufferGeometry, + Float32BufferAttribute, + Mesh, + Vector2, + Vector3, +} from 'three'; +import { QuadtreeMesher } from './quadtree-mesher'; + +export enum LODQuadrant { + TOP_LEFT, + TOP_RIGHT, + BOTTOM_RIGHT, + BOTTOM_LEFT, +} + +export enum LODSide { + TOP, + RIGHT, + BOTTOM, + LEFT, +} + +export function mirrorSide(x: number): number { + return (x + 2) % 4; +} + +export function isAdjacent(s: number, q: number): boolean { + return (4 + q - s) % 4 <= 1; +} + +export function reflectSide(s: number, q: number): number { + return s % 2 ? (q % 2 ? q - 1 : q + 1) : 3 - q; +} + +export class QuadtreeNode { + public _children: QuadtreeNode[] = []; + public _neighbors: QuadtreeNode[] = []; + private _leaf = true; + private _mesh?: Mesh; + + constructor( + public root: QuadtreeMesher, + public parent: QuadtreeNode, + public level: number, + public quadrant: number, + public position: Vector2, + ) {} + + public dispose() { + if (this._mesh) { + this._destroyMesh(); + } + this._children.forEach((child) => child.dispose()); + this._children.length = 0; + } + + public isMeshed() { + return !!this._mesh; + } + + public update(camera: Vector3) { + if (this.root.actionsLeft === 0) { + return; + } + + const size = this.root.size / Math.pow(2, this.level); + const abs = new Vector3( + this.position.x + size / 2, + camera.y, + this.position.y + size / 2, + ); + + if (this._leaf) { + if (abs.distanceTo(camera) < size && this._canSubdivide()) { + this._subdivide(); + this.root.actionsLeft -= 1; + return; + } + + if (!this._mesh) { + this._createMesh(); + this.root.actionsLeft -= 1; + return; + } + } else if (!this._leaf) { + if (abs.distanceTo(camera) > size) { + this._merge(); + this.root.actionsLeft -= 1; + } + + if ( + this.isMeshed() && + this._children.every((child) => child.isMeshed()) + ) { + this._destroyMesh(); + } + + this._children.forEach((child) => child.update(camera)); + } + } + + public setNeighbor(side: LODSide, neighbor: QuadtreeNode) { + this._neighbors[side] = neighbor; + neighbor._neighbors[mirrorSide(side)] = this; + } + + public findNeighbor(side: LODSide) { + if (!this._neighbors[side] && this.parent && this.parent._neighbors[side]) { + const neighbor = + this.parent._neighbors[side]?._children[ + reflectSide(side, this.quadrant) + ]; + if (neighbor) { + this.setNeighbor(side, neighbor); + } else { + return; + } + } + + if (!this._leaf) { + for (let i = 0; i < 4; i++) { + if (isAdjacent(side, i)) { + this._children[i].findNeighbor(side); + } + } + } + } + + private _createGeometry(): BufferGeometry { + const apparentSize = this.root.size / Math.pow(2, this.level); + const vertCount = + this.root.size / Math.pow(2, this.root.maxDepth - this.level); + const divisionLevel = Math.pow(2, this.level); + const geometry = new BufferGeometry(); + const vertices = []; + const normals = []; + const indices = []; + const uvs = []; + + for (let x = 0; x < vertCount; x++) { + for (let y = 0; y < vertCount; y++) { + const vertDivj = y / (vertCount - 1); + const vertDivi = x / (vertCount - 1); + + const absX = this.position.x + (y / (vertCount - 1)) * apparentSize; + const absY = this.position.y + (x / (vertCount - 1)) * apparentSize; + const normal = this.root.getNormal(absX, absY); + + // Calculate relative resolution + const pj = vertDivj * (this.root.size / divisionLevel); + const pi = vertDivi * (this.root.size / divisionLevel); + + vertices.push(pj, this.root.getHeight(absX, absY), pi); + normals.push(normal.x, normal.y, normal.z); + uvs.push(absX / (this.root.size - 1), absY / (this.root.size - 1)); + } + } + + for (let x = 0; x < vertCount - 1; x++) { + for (let y = 0; y < vertCount - 1; y++) { + const topLeft = x * vertCount + y; + const topRight = topLeft + 1; + const bottomLeft = (x + 1) * vertCount + y; + const bottomRight = bottomLeft + 1; + indices.push( + topLeft, + bottomLeft, + topRight, + topRight, + bottomLeft, + bottomRight, + ); + } + } + + geometry.setIndex(indices); + geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3)); + geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3)); + geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)); + + return geometry; + } + + private _merge(): boolean { + if (this._leaf) { + return false; + } + + this._children.forEach((child) => child.dispose()); + this._children.length = 0; + this._leaf = true; + return true; + } + + private _subdivide(): boolean { + if (!this._canSubdivide()) { + return false; + } + + const level = this.level + 1; + const stepLeft = this.root.size / Math.pow(2, level); + const stepForward = this.root.size / Math.pow(2, level); + + const { x, y } = this.position; + + // Create children + this._children[LODQuadrant.TOP_LEFT] = new QuadtreeNode( + this.root, + this, + level, + LODQuadrant.TOP_LEFT, + new Vector2(x, y), + ); + this._children[LODQuadrant.TOP_RIGHT] = new QuadtreeNode( + this.root, + this, + level, + LODQuadrant.TOP_RIGHT, + new Vector2(stepLeft + x, y), + ); + this._children[LODQuadrant.BOTTOM_RIGHT] = new QuadtreeNode( + this.root, + this, + level, + LODQuadrant.BOTTOM_RIGHT, + new Vector2(stepLeft + x, stepForward + y), + ); + this._children[LODQuadrant.BOTTOM_LEFT] = new QuadtreeNode( + this.root, + this, + level, + LODQuadrant.BOTTOM_LEFT, + new Vector2(x, stepForward + y), + ); + + // Set sibling neighbors + this._children[LODQuadrant.TOP_LEFT].setNeighbor( + LODSide.RIGHT, + this._children[LODQuadrant.TOP_RIGHT], + ); + this._children[LODQuadrant.TOP_RIGHT].setNeighbor( + LODSide.BOTTOM, + this._children[LODQuadrant.BOTTOM_RIGHT], + ); + this._children[LODQuadrant.BOTTOM_RIGHT].setNeighbor( + LODSide.LEFT, + this._children[LODQuadrant.BOTTOM_LEFT], + ); + this._children[LODQuadrant.BOTTOM_LEFT].setNeighbor( + LODSide.TOP, + this._children[LODQuadrant.TOP_LEFT], + ); + + // set adjacent neighbors + for (let i = 0; i < 4; i++) { + if (this._neighbors[i] && !this._neighbors[i]._leaf) { + this._neighbors[i].findNeighbor(mirrorSide(i)); + } + } + + this._leaf = false; + return true; + } + + private _createMesh() { + if (this._mesh) { + this.root.container.remove(this._mesh); + this._mesh = null; + } + const geometry = this._createGeometry(); + const mesh = new Mesh(geometry, this.root.material); + mesh.position.set(this.position.x, 0, this.position.y); + this.root.container.add(mesh); + this._mesh = mesh; + } + + private _destroyMesh() { + this.root.container.remove(this._mesh); + this._mesh = null; + } + + private _canSubdivide() { + return this._leaf && this.level < this.root.maxDepth - 1; + } +}