diff --git a/assets/terrain/height-0-0.png b/assets/terrain/height-0-0.png new file mode 100644 index 0000000..9e876e1 Binary files /dev/null and b/assets/terrain/height-0-0.png differ diff --git a/assets/terrain/splat-0-0.png b/assets/terrain/splat-0-0.png new file mode 100644 index 0000000..0e1bc89 Binary files /dev/null and b/assets/terrain/splat-0-0.png differ diff --git a/assets/terrain/texture/grass-flowers.png b/assets/terrain/texture/grass-flowers.png new file mode 100644 index 0000000..9da0771 Binary files /dev/null and b/assets/terrain/texture/grass-flowers.png differ diff --git a/assets/terrain/texture/grassy.png b/assets/terrain/texture/grassy.png new file mode 100644 index 0000000..9c9b143 Binary files /dev/null and b/assets/terrain/texture/grassy.png differ diff --git a/assets/terrain/texture/mud.png b/assets/terrain/texture/mud.png new file mode 100644 index 0000000..dc1632f Binary files /dev/null and b/assets/terrain/texture/mud.png differ diff --git a/assets/terrain/texture/path.png b/assets/terrain/texture/path.png new file mode 100644 index 0000000..84b3bac Binary files /dev/null and b/assets/terrain/texture/path.png differ diff --git a/assets/terrain/texture/simplex-noise.png b/assets/terrain/texture/simplex-noise.png new file mode 100644 index 0000000..96f313c Binary files /dev/null and b/assets/terrain/texture/simplex-noise.png differ diff --git a/src/client/game.ts b/src/client/game.ts index 91b035b..9531bca 100644 --- a/src/client/game.ts +++ b/src/client/game.ts @@ -1,4 +1,3 @@ -import e from 'express'; import { Socket } from 'socket.io-client'; import { Color } from 'three'; import { isMobileOrTablet } from '../common/helper'; @@ -11,6 +10,8 @@ import { VideoPlayer } from './object/other/video-player'; import { Player } from './object/player'; import { PlayerEntity } from './object/player-entity'; import modelLoaderInstance from './object/pony-loader'; +import { ClientWorld } from './object/world/ClientWorld'; +import { ClientWorldLoader } from './object/world/ClientWorldLoader'; import { Renderer } from './renderer'; export class Game { @@ -23,6 +24,7 @@ export class Game { private character: CharacterPacket = {}; private party: string[] = []; + public world = new ClientWorld(new ClientWorldLoader()); public renderer = new Renderer(); private videoTest = new VideoPlayer(24, 12); @@ -31,6 +33,7 @@ export class Game { async initialize(): Promise { await modelLoaderInstance.loadPonyModel(); + await this.world.initialize(); this.renderer.initialize(); this.bindSocket(); @@ -59,6 +62,8 @@ export class Game { this.renderer.registerUpdateFunction((dt: number) => { this.update(dt); }); + + this.renderer.scene.add(this.world.world); } public dispose() { @@ -77,6 +82,8 @@ export class Game { this.player?.createPacket(this.socket); this.thirdPersonCamera?.update(dt); this.joystick?.update(dt); + + this.world?.update(dt); } bindSocket() { @@ -96,7 +103,7 @@ export class Game { this.me = user; this.chat.addMessage( - `Welcome to Icy3d World Experiment, ${user.display_name}!`, + `Welcome to Icy3D World Experiment, ${user.display_name}!`, null, { color: '#fbff4e', @@ -105,6 +112,7 @@ export class Game { const player = Player.fromUser(user, this.renderer.scene); player.setCharacter(this.character); + player.setHeightSource(this.world); this.players.push(player); this.player = player; this.thirdPersonCamera = new ThirdPersonCamera( @@ -130,6 +138,7 @@ export class Game { } const newplayer = PlayerEntity.fromUser(user, this.renderer.scene); + newplayer.setHeightSource(this.world); this.chat.addMessage(`${user.display_name} has joined the game.`, null, { color: '#fbff4e', }); @@ -156,6 +165,7 @@ export class Game { } const newplayer = PlayerEntity.fromUser(player, this.renderer.scene); + newplayer.setHeightSource(this.world); newplayer.addUncommittedChanges(player); this.players.push(newplayer); }); diff --git a/src/client/object/player.ts b/src/client/object/player.ts index 6841e02..9b04bb4 100644 --- a/src/client/object/player.ts +++ b/src/client/object/player.ts @@ -97,7 +97,7 @@ export class Player extends PonyEntity { } if (vector.y !== 0) { - this.velocity.copy(this._lookVector.clone().multiplyScalar(vector.y)); + this.velocity.copy(this._lookVector.clone().multiplyScalar(vector.y * 5)); this.changes.velocity = this.velocity.toArray(); this._wasMoving = true; diff --git a/src/client/object/pony.ts b/src/client/object/pony.ts index f815fae..7a8b0e9 100644 --- a/src/client/object/pony.ts +++ b/src/client/object/pony.ts @@ -12,6 +12,7 @@ import { Object3D, Vector3, } from 'three'; +import { ClientWorld } from './world/ClientWorld'; const nameTagBuilder = new CanvasUtils({ fill: false, @@ -33,6 +34,7 @@ export class PonyEntity { public walkAction: AnimationAction; public nameTag?: NameTag; public changes: FullStatePacket = {}; + public heightSource?: ClientWorld; initialize() { this.model = (SkeletonUtils as any).clone(modelLoaderInstance.ponyModel); @@ -58,6 +60,14 @@ export class PonyEntity { this.container.rotation.z, ).add(this.angularVelocity.clone().multiplyScalar(dt)), ); + + // Put pony on the terrain + const terrainFloorHeight = this.heightSource?.getInterpolatedHeight( + this.container.position.x, + this.container.position.z, + ) || 0; + this.container.position.y = terrainFloorHeight; + this.mixer.update(dt); } @@ -98,4 +108,8 @@ export class PonyEntity { this.setColor(packet.color); } } + + public setHeightSource(source: ClientWorld) { + this.heightSource = source; + } } diff --git a/src/client/object/world/ClientWorld.ts b/src/client/object/world/ClientWorld.ts new file mode 100644 index 0000000..6ebc43a --- /dev/null +++ b/src/client/object/world/ClientWorld.ts @@ -0,0 +1,95 @@ +import { Mesh, Object3D, 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'; + +// TODO: distance loading +// TODO: LOD + +export class ClientWorld extends WorldManager { + public world = new Object3D(); + private _mesher = new ClientWorldMesher(); + private _chunkMeshes: Mesh[] = []; + private _chunkMeshQueue: WorldChunk[] = []; + private _worldTextures: Map = new Map(); + private _shader = new ClientWorldChunkShader(this._worldTextures); + + getNormalVector(x: number, y: number): Vector3 { + const heightL = this.getHeight(x - 1, y); + const heightR = this.getHeight(x + 1, y); + const heightD = this.getHeight(x, y - 1); + const heightU = this.getHeight(x, y + 1); + const normalized = new Vector3(heightL - heightR, 2, heightD - heightU); + normalized.normalize(); + return normalized; + } + + async initialize() { + await this.loadWorld(); + await this.loadTextureList([ + ...this._chunks.map( + (chunk) => `/assets/terrain/splat-${chunk.x}-${chunk.y}.png`, + ), + '/assets/terrain/texture/simplex-noise.png', + '/assets/terrain/texture/grassy.png', + '/assets/terrain/texture/mud.png', + '/assets/terrain/texture/grass-flowers.png', + '/assets/terrain/texture/path.png', + ]); + this._shader.initialize( + this._worldTextures.get('/assets/terrain/texture/simplex-noise.png'), + ); + this.createMeshes(); + } + + async loadTexture(src: string): Promise { + if (this._worldTextures.has(src)) { + return this._worldTextures.get(src); + } + + const tex = await ClientWorldTexture.loadTexture(src); + // tex.texture.repeat.set(this.worldChunkSize, this.worldChunkSize); + this._worldTextures.set(src, tex); + return tex; + } + + async loadTextureList(srcList: string[]): Promise { + for (const src of srcList) { + await this.loadTexture(src); + } + } + + update(dt: number) { + if (this._chunkMeshQueue.length) { + const chunk = this._chunkMeshQueue.shift(); + const material = this._shader.getShader( + chunk, + `/assets/terrain/splat-${chunk.x}-${chunk.y}.png`, + [ + '/assets/terrain/texture/grassy.png', + '/assets/terrain/texture/mud.png', + '/assets/terrain/texture/grass-flowers.png', + '/assets/terrain/texture/path.png', + ], + ); + + const mesh = this._mesher.createTerrainMesh( + chunk, + material, + this.getHeight.bind(this), + this.getNormalVector.bind(this), + ); + + this._chunkMeshes.push(mesh); + this.world.add(mesh); + } + } + + private createMeshes() { + this._chunks.forEach((chunk) => { + this._chunkMeshQueue.push(chunk); + }); + } +} diff --git a/src/client/object/world/ClientWorldChunkShader.ts b/src/client/object/world/ClientWorldChunkShader.ts new file mode 100644 index 0000000..469f390 --- /dev/null +++ b/src/client/object/world/ClientWorldChunkShader.ts @@ -0,0 +1,218 @@ +import { + Color, + MultiplyOperation, + ShaderMaterial, + UniformsLib, + UniformsUtils, + Vector3, +} from 'three'; +import { WorldChunk } from '../../../common/world/WorldChunk'; +import { ClientWorldTexture } from './ClientWorldTexture'; + +// Adapted from the Lambert Material shader +// https://github.com/mrdoob/three.js/blob/44837d13a1bc0cf59824f3a6ddfab19ecd5ff435/src/renderers/shaders/ShaderLib/meshlambert.glsl.js + +const vertex = /* glsl */ ` +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +varying vec3 vLightFront; +varying vec3 vIndirectFront; +varying vec2 vUv; + +void main() { + vUv = uv; + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include +} +`; + +const fragment = /* glsl */ ` + +varying vec3 vLightFront; +varying vec3 vIndirectFront; + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +uniform sampler2D backgroundTex; +uniform sampler2D noiseTex; +uniform sampler2D rTex; +uniform sampler2D gTex; +uniform sampler2D bTex; +uniform sampler2D splatMap; + +uniform float chunkSize; + +varying vec2 vUv; +float sum( vec3 v ) { return v.x+v.y+v.z; } + +// https://www.shadertoy.com/view/lt2GDd +vec4 textureNoTile(sampler2D samp, in vec2 x) +{ + float k = texture( noiseTex, 0.005*x ).x; // cheap (cache friendly) lookup + + vec2 duvdx = dFdx( x ); + vec2 duvdy = dFdx( x ); + + float l = k*8.0; + float f = fract(l); + + float ia = floor(l+0.5); + float ib = floor(l); + f = min(f, 1.0-f)*2.0; + + vec2 offa = sin(vec2(3.0,7.0)*ia); // can replace with any other hash + vec2 offb = sin(vec2(3.0,7.0)*ib); // can replace with any other hash + + vec3 cola = textureGrad( samp, x + 0.8 *offa, duvdx, duvdy ).xyz; + vec3 colb = textureGrad( samp, x + 0.8 *offb, duvdx, duvdy ).xyz; + + return vec4(mix( cola, colb, smoothstep(0.2,0.8,f-0.1*sum(cola-colb)) ), 1.0); +} + +void main() { + #include + + vec4 splatMapColor = texture2D(splatMap, vUv); + float backTextureAmount = 1.0 - (splatMapColor.r + splatMapColor.g + splatMapColor.b); + vec2 tiledCoords = vUv * chunkSize; + vec4 backgroundTextureColor = textureNoTile(backgroundTex, tiledCoords) * backTextureAmount; + vec4 rTextureAmount = textureNoTile(rTex, tiledCoords) * splatMapColor.r; + vec4 gTextureAmount = textureNoTile(gTex, tiledCoords) * splatMapColor.g; + vec4 bTextureAmount = textureNoTile(bTex, tiledCoords) * splatMapColor.b; + + vec4 diffuseColor = backgroundTextureColor + rTextureAmount + gTextureAmount + bTextureAmount; + + ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) ); + + #include + #include + #include + #include + #include + #include + #include + + reflectedLight.indirectDiffuse += vIndirectFront; + + #include + reflectedLight.indirectDiffuse *= BRDF_Lambert( diffuseColor.rgb ); + reflectedLight.directDiffuse = vLightFront; + reflectedLight.directDiffuse *= BRDF_Lambert( diffuseColor.rgb ) * getShadowMask(); + + #include + vec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse; + + #include + #include + #include + #include + #include + #include + #include +} +`; + +export class ClientWorldChunkShader { + public shader!: ShaderMaterial; + + constructor(public textureList: Map) {} + + initialize(noise: ClientWorldTexture) { + this.shader = new ShaderMaterial({ + vertexShader: vertex, + fragmentShader: fragment, + lights: true, + uniforms: UniformsUtils.merge([ + UniformsLib.common, + UniformsLib.specularmap, + UniformsLib.envmap, + UniformsLib.aomap, + UniformsLib.lightmap, + UniformsLib.emissivemap, + UniformsLib.fog, + UniformsLib.lights, + { + backgroundTex: { value: null, type: 't' }, + noiseTex: { value: noise.texture, type: 't' }, + rTex: { value: null, type: 't' }, + gTex: { value: null, type: 't' }, + bTex: { value: null, type: 't' }, + splatMap: { value: null, type: 't' }, + chunkSize: { value: null }, + }, + ]), + }); + } + + public getShader( + chunk: WorldChunk, + splatMap: string, + textureList: string[], + ): ShaderMaterial { + const clone = this.shader.clone(); + + const splat = this.textureList.get(splatMap); + const [bg, r, g, b] = textureList.map((item) => this.textureList.get(item)); + + clone.uniforms.chunkSize.value = chunk.size / 2; + clone.uniforms.splatMap.value = splat.texture; + clone.uniforms.backgroundTex.value = bg.texture; + clone.uniforms.rTex.value = r.texture; + clone.uniforms.gTex.value = g.texture; + clone.uniforms.bTex.value = b.texture; + + return clone; + } +} diff --git a/src/client/object/world/ClientWorldLoader.ts b/src/client/object/world/ClientWorldLoader.ts new file mode 100644 index 0000000..94caa84 --- /dev/null +++ b/src/client/object/world/ClientWorldLoader.ts @@ -0,0 +1,40 @@ +import { ImageLoader } from 'three'; +import { to1D } from '../../../common/convert'; +import { WorldLoader } from '../../../common/world/WorldLoader'; + +const loader = new ImageLoader(); +const worldPath = '/assets/terrain/'; + +export class ClientWorldLoader implements WorldLoader { + async loadHeightMap(chunkX: number, chunkY: number): Promise { + return new Promise((resolve, reject) => { + loader.load( + `${worldPath}/height-${chunkX}-${chunkY}.png`, + (data) => resolve(ClientWorldLoader.heightFromImage(data)), + undefined, + (err) => { + reject(err); + }, + ); + }); + } + + public static heightFromImage(image: HTMLImageElement): number[] { + const array = new Array(image.width * image.height); + const ctx = document.createElement('canvas').getContext('2d'); + ctx.canvas.width = image.width; + ctx.canvas.height = image.height; + ctx.drawImage(image, 0, 0, image.width, image.height); + + // pixel data + const data = ctx.getImageData(0, 0, image.width, image.height); + for (let x = 0; x < image.width; x++) { + for (let y = 0; y < image.height; y++) { + const index = to1D(x, y, image.width); + array[index] = (data.data[index * 4] * 32) / 255; + } + } + + return array; + } +} diff --git a/src/client/object/world/ClientWorldMesher.ts b/src/client/object/world/ClientWorldMesher.ts new file mode 100644 index 0000000..d3195c3 --- /dev/null +++ b/src/client/object/world/ClientWorldMesher.ts @@ -0,0 +1,77 @@ +import { + BufferGeometry, + Float32BufferAttribute, + Material, + Mesh, + MeshLambertMaterial, + Vector3, +} from 'three'; +import { to2D } from '../../../common/convert'; +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/ClientWorldTexture.ts b/src/client/object/world/ClientWorldTexture.ts new file mode 100644 index 0000000..e1dad4c --- /dev/null +++ b/src/client/object/world/ClientWorldTexture.ts @@ -0,0 +1,18 @@ +import { RepeatWrapping, Texture, TextureLoader } from 'three'; + +const loader = new TextureLoader(); + +export class ClientWorldTexture { + constructor(public source: string, public texture: Texture) {} + + public static async loadTexture(src: string): Promise { + const texture = await new Promise((resolve, reject) => { + const load = loader.load(src, resolve, undefined, reject); + }); + + const worldTexture = new ClientWorldTexture(src, texture); + texture.wrapS = RepeatWrapping; + texture.wrapT = RepeatWrapping; + return worldTexture; + } +} diff --git a/src/client/renderer.ts b/src/client/renderer.ts index e03591f..d575cf1 100644 --- a/src/client/renderer.ts +++ b/src/client/renderer.ts @@ -57,7 +57,7 @@ export class Renderer { ); this.ground.position.set(0, -0.5, 0); - this.scene.add(this.ground); + //this.scene.add(this.ground); this.camera.position.set(0, 4, 4); diff --git a/src/common/helper.ts b/src/common/helper.ts index 3d62de9..b07fe1e 100644 --- a/src/common/helper.ts +++ b/src/common/helper.ts @@ -1,3 +1,5 @@ +import { Vector3, Vector2 } from 'three'; + export function clamp(x: number, min: number, max: number): number { return Math.min(Math.max(x, min), max); } @@ -31,3 +33,19 @@ export function isMobileOrTablet(): boolean { export function rand(randgen: () => number, min: number, max: number) { return Math.floor(randgen() * (max - min + 1)) + min; } + +// https://en.wikipedia.org/wiki/Barycentric_coordinate_system#Barycentric_coordinates_on_triangles +export function barycentricPoint( + p1: Vector3, + p2: Vector3, + p3: Vector3, + pos: Vector2, +) { + const det = (p2.z - p3.z) * (p1.x - p3.x) + (p3.x - p2.x) * (p1.z - p3.z); + const l1 = + ((p2.z - p3.z) * (pos.x - p3.x) + (p3.x - p2.x) * (pos.y - p3.z)) / det; + const l2 = + ((p3.z - p1.z) * (pos.x - p3.x) + (p1.x - p3.x) * (pos.y - p3.z)) / det; + const l3 = 1.0 - l1 - l2; + return l1 * p1.y + l2 * p2.y + l3 * p3.y; +} diff --git a/src/common/world/WorldChunk.ts b/src/common/world/WorldChunk.ts new file mode 100644 index 0000000..9d16b2e --- /dev/null +++ b/src/common/world/WorldChunk.ts @@ -0,0 +1,49 @@ +import { Vector3, Vector2 } from 'three'; +import { to1D } from '../convert'; +import { barycentricPoint } from '../helper'; + +export class WorldChunk { + constructor( + public heightData: number[], + public x: number, + public y: number, + public size: number, + public scaledSize = size, + ) {} + + public getPoint(x: number, y: number): number { + return this.heightData[to1D(Math.floor(x), Math.floor(y), this.size)]; + } + + public getInterpolatedPoint(x: number, y: number): number { + const terrainX = x - this.x * this.size; + const terrainY = y - this.y * this.size; + const gridSquareSize = this.scaledSize / this.size; + const gridX = Math.floor(x / gridSquareSize); + const gridY = Math.floor(y / gridSquareSize); + + if (gridX >= this.size || gridY >= this.size || gridX < 0 || gridY < 0) { + return 0; + } + + const xCoord = (terrainX % gridSquareSize) / gridSquareSize; + const yCoord = (terrainY % gridSquareSize) / gridSquareSize; + let result: number; + if (xCoord <= 1 - yCoord) { + result = barycentricPoint( + new Vector3(0, this.getPoint(gridX, gridY), 0), + new Vector3(1, this.getPoint(gridX + 1, gridY), 0), + new Vector3(0, this.getPoint(gridX, gridY + 1), 1), + new Vector2(xCoord, yCoord), + ); + } else { + result = barycentricPoint( + new Vector3(1, this.getPoint(gridX + 1, gridY), 0), + new Vector3(1, this.getPoint(gridX + 1, gridY + 1), 1), + new Vector3(0, this.getPoint(gridX, gridY + 1), 1), + new Vector2(xCoord, yCoord), + ); + } + return result; + } +} diff --git a/src/common/world/WorldLoader.ts b/src/common/world/WorldLoader.ts new file mode 100644 index 0000000..c41628f --- /dev/null +++ b/src/common/world/WorldLoader.ts @@ -0,0 +1,3 @@ +export interface WorldLoader { + loadHeightMap: (chunkX: number, chunkY: number) => Promise; +} diff --git a/src/common/world/WorldManager.ts b/src/common/world/WorldManager.ts new file mode 100644 index 0000000..c528c7d --- /dev/null +++ b/src/common/world/WorldManager.ts @@ -0,0 +1,69 @@ +import { to1D } from '../convert'; +import { WorldChunk } from './WorldChunk'; +import { WorldLoader } from './WorldLoader'; + +export class WorldManager { + protected _chunks!: WorldChunk[]; + + constructor( + public loader: WorldLoader, + public worldWidth = 1, + public worldHeight = 1, + public worldChunkSize = 256, + ) { + this._chunks = new Array(this.worldWidth * this.worldHeight); + } + + async loadHeightData(chunkX: number, chunkY: number) { + const heightData = await this.loader.loadHeightMap(chunkX, chunkY); + this._chunks[to1D(chunkX, chunkY, this.worldWidth)] = new WorldChunk( + heightData, + chunkX, chunkY, + this.worldChunkSize, + ); + } + + async loadWorld() { + for (let x = 0; x < this.worldWidth; x++) { + for (let y = 0; y < this.worldHeight; y++) { + await this.loadHeightData(x, y); + } + } + } + + getHeight(x: number, y: number): number { + const chunkX = Math.floor(x / this.worldChunkSize); + const chunkY = Math.floor(y / this.worldChunkSize); + if ( + chunkX >= this.worldWidth || + chunkY >= this.worldHeight || + x < 0 || + y < 0 + ) { + return 0; + } + + return this._chunks[to1D(chunkX, chunkY, this.worldWidth)].getPoint( + x - chunkX * this.worldChunkSize, + y - chunkY * this.worldChunkSize, + ); + } + + getInterpolatedHeight(x: number, y: number): number { + const chunkX = Math.floor(x / this.worldChunkSize); + const chunkY = Math.floor(y / this.worldChunkSize); + if ( + chunkX >= this.worldWidth || + chunkY >= this.worldHeight || + x < 0 || + y < 0 + ) { + return 0; + } + + return this._chunks[to1D(chunkX, chunkY, this.worldWidth)].getInterpolatedPoint( + x - chunkX * this.worldChunkSize, + y - chunkY * this.worldChunkSize, + ); + } +}