starting with mapped terrains
BIN
assets/terrain/height-0-0.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
assets/terrain/splat-0-0.png
Normal file
After Width: | Height: | Size: 554 KiB |
BIN
assets/terrain/texture/grass-flowers.png
Normal file
After Width: | Height: | Size: 162 KiB |
BIN
assets/terrain/texture/grassy.png
Normal file
After Width: | Height: | Size: 123 KiB |
BIN
assets/terrain/texture/mud.png
Normal file
After Width: | Height: | Size: 60 KiB |
BIN
assets/terrain/texture/path.png
Normal file
After Width: | Height: | Size: 426 KiB |
BIN
assets/terrain/texture/simplex-noise.png
Normal file
After Width: | Height: | Size: 385 KiB |
@ -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<void> {
|
||||
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);
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
95
src/client/object/world/ClientWorld.ts
Normal file
@ -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<string, ClientWorldTexture> = 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<ClientWorldTexture> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
218
src/client/object/world/ClientWorldChunkShader.ts
Normal file
@ -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 <common>
|
||||
#include <uv_pars_vertex>
|
||||
#include <uv2_pars_vertex>
|
||||
#include <envmap_pars_vertex>
|
||||
#include <bsdfs>
|
||||
#include <lights_pars_begin>
|
||||
#include <color_pars_vertex>
|
||||
#include <fog_pars_vertex>
|
||||
#include <morphtarget_pars_vertex>
|
||||
#include <skinning_pars_vertex>
|
||||
#include <shadowmap_pars_vertex>
|
||||
#include <logdepthbuf_pars_vertex>
|
||||
#include <clipping_planes_pars_vertex>
|
||||
|
||||
varying vec3 vLightFront;
|
||||
varying vec3 vIndirectFront;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
#include <uv_vertex>
|
||||
#include <uv2_vertex>
|
||||
#include <color_vertex>
|
||||
#include <morphcolor_vertex>
|
||||
#include <beginnormal_vertex>
|
||||
#include <morphnormal_vertex>
|
||||
#include <skinbase_vertex>
|
||||
#include <skinnormal_vertex>
|
||||
#include <defaultnormal_vertex>
|
||||
#include <begin_vertex>
|
||||
#include <morphtarget_vertex>
|
||||
#include <skinning_vertex>
|
||||
#include <project_vertex>
|
||||
#include <logdepthbuf_vertex>
|
||||
#include <clipping_planes_vertex>
|
||||
#include <worldpos_vertex>
|
||||
#include <envmap_vertex>
|
||||
#include <lights_lambert_vertex>
|
||||
#include <shadowmap_vertex>
|
||||
#include <fog_vertex>
|
||||
}
|
||||
`;
|
||||
|
||||
const fragment = /* glsl */ `
|
||||
|
||||
varying vec3 vLightFront;
|
||||
varying vec3 vIndirectFront;
|
||||
|
||||
#include <common>
|
||||
#include <packing>
|
||||
#include <dithering_pars_fragment>
|
||||
#include <color_pars_fragment>
|
||||
#include <uv_pars_fragment>
|
||||
#include <uv2_pars_fragment>
|
||||
#include <map_pars_fragment>
|
||||
#include <alphamap_pars_fragment>
|
||||
#include <alphatest_pars_fragment>
|
||||
#include <aomap_pars_fragment>
|
||||
#include <lightmap_pars_fragment>
|
||||
#include <emissivemap_pars_fragment>
|
||||
#include <envmap_common_pars_fragment>
|
||||
#include <envmap_pars_fragment>
|
||||
#include <cube_uv_reflection_fragment>
|
||||
#include <bsdfs>
|
||||
#include <lights_pars_begin>
|
||||
#include <fog_pars_fragment>
|
||||
#include <shadowmap_pars_fragment>
|
||||
#include <shadowmask_pars_fragment>
|
||||
#include <specularmap_pars_fragment>
|
||||
#include <logdepthbuf_pars_fragment>
|
||||
#include <clipping_planes_pars_fragment>
|
||||
|
||||
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 <clipping_planes_fragment>
|
||||
|
||||
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 <logdepthbuf_fragment>
|
||||
#include <map_fragment>
|
||||
#include <color_fragment>
|
||||
#include <alphamap_fragment>
|
||||
#include <alphatest_fragment>
|
||||
#include <specularmap_fragment>
|
||||
#include <emissivemap_fragment>
|
||||
|
||||
reflectedLight.indirectDiffuse += vIndirectFront;
|
||||
|
||||
#include <lightmap_fragment>
|
||||
reflectedLight.indirectDiffuse *= BRDF_Lambert( diffuseColor.rgb );
|
||||
reflectedLight.directDiffuse = vLightFront;
|
||||
reflectedLight.directDiffuse *= BRDF_Lambert( diffuseColor.rgb ) * getShadowMask();
|
||||
|
||||
#include <aomap_fragment>
|
||||
vec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse;
|
||||
|
||||
#include <envmap_fragment>
|
||||
#include <output_fragment>
|
||||
#include <tonemapping_fragment>
|
||||
#include <encodings_fragment>
|
||||
#include <fog_fragment>
|
||||
#include <premultiplied_alpha_fragment>
|
||||
#include <dithering_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
export class ClientWorldChunkShader {
|
||||
public shader!: ShaderMaterial;
|
||||
|
||||
constructor(public textureList: Map<string, ClientWorldTexture>) {}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
40
src/client/object/world/ClientWorldLoader.ts
Normal file
@ -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<number[]> {
|
||||
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;
|
||||
}
|
||||
}
|
77
src/client/object/world/ClientWorldMesher.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
18
src/client/object/world/ClientWorldTexture.ts
Normal file
@ -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<ClientWorldTexture> {
|
||||
const texture = await new Promise<Texture>((resolve, reject) => {
|
||||
const load = loader.load(src, resolve, undefined, reject);
|
||||
});
|
||||
|
||||
const worldTexture = new ClientWorldTexture(src, texture);
|
||||
texture.wrapS = RepeatWrapping;
|
||||
texture.wrapT = RepeatWrapping;
|
||||
return worldTexture;
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
49
src/common/world/WorldChunk.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
3
src/common/world/WorldLoader.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface WorldLoader {
|
||||
loadHeightMap: (chunkX: number, chunkY: number) => Promise<number[]>;
|
||||
}
|
69
src/common/world/WorldManager.ts
Normal file
@ -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,
|
||||
);
|
||||
}
|
||||
}
|