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 { Socket } from 'socket.io-client';
|
||||||
import { Color } from 'three';
|
import { Color } from 'three';
|
||||||
import { isMobileOrTablet } from '../common/helper';
|
import { isMobileOrTablet } from '../common/helper';
|
||||||
@ -11,6 +10,8 @@ import { VideoPlayer } from './object/other/video-player';
|
|||||||
import { Player } from './object/player';
|
import { Player } from './object/player';
|
||||||
import { PlayerEntity } from './object/player-entity';
|
import { PlayerEntity } from './object/player-entity';
|
||||||
import modelLoaderInstance from './object/pony-loader';
|
import modelLoaderInstance from './object/pony-loader';
|
||||||
|
import { ClientWorld } from './object/world/ClientWorld';
|
||||||
|
import { ClientWorldLoader } from './object/world/ClientWorldLoader';
|
||||||
import { Renderer } from './renderer';
|
import { Renderer } from './renderer';
|
||||||
|
|
||||||
export class Game {
|
export class Game {
|
||||||
@ -23,6 +24,7 @@ export class Game {
|
|||||||
private character: CharacterPacket = {};
|
private character: CharacterPacket = {};
|
||||||
private party: string[] = [];
|
private party: string[] = [];
|
||||||
|
|
||||||
|
public world = new ClientWorld(new ClientWorldLoader());
|
||||||
public renderer = new Renderer();
|
public renderer = new Renderer();
|
||||||
|
|
||||||
private videoTest = new VideoPlayer(24, 12);
|
private videoTest = new VideoPlayer(24, 12);
|
||||||
@ -31,6 +33,7 @@ export class Game {
|
|||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
await modelLoaderInstance.loadPonyModel();
|
await modelLoaderInstance.loadPonyModel();
|
||||||
|
await this.world.initialize();
|
||||||
|
|
||||||
this.renderer.initialize();
|
this.renderer.initialize();
|
||||||
this.bindSocket();
|
this.bindSocket();
|
||||||
@ -59,6 +62,8 @@ export class Game {
|
|||||||
this.renderer.registerUpdateFunction((dt: number) => {
|
this.renderer.registerUpdateFunction((dt: number) => {
|
||||||
this.update(dt);
|
this.update(dt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.renderer.scene.add(this.world.world);
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose() {
|
public dispose() {
|
||||||
@ -77,6 +82,8 @@ export class Game {
|
|||||||
this.player?.createPacket(this.socket);
|
this.player?.createPacket(this.socket);
|
||||||
this.thirdPersonCamera?.update(dt);
|
this.thirdPersonCamera?.update(dt);
|
||||||
this.joystick?.update(dt);
|
this.joystick?.update(dt);
|
||||||
|
|
||||||
|
this.world?.update(dt);
|
||||||
}
|
}
|
||||||
|
|
||||||
bindSocket() {
|
bindSocket() {
|
||||||
@ -96,7 +103,7 @@ export class Game {
|
|||||||
this.me = user;
|
this.me = user;
|
||||||
|
|
||||||
this.chat.addMessage(
|
this.chat.addMessage(
|
||||||
`Welcome to Icy3d World Experiment, ${user.display_name}!`,
|
`Welcome to Icy3D World Experiment, ${user.display_name}!`,
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
color: '#fbff4e',
|
color: '#fbff4e',
|
||||||
@ -105,6 +112,7 @@ export class Game {
|
|||||||
|
|
||||||
const player = Player.fromUser(user, this.renderer.scene);
|
const player = Player.fromUser(user, this.renderer.scene);
|
||||||
player.setCharacter(this.character);
|
player.setCharacter(this.character);
|
||||||
|
player.setHeightSource(this.world);
|
||||||
this.players.push(player);
|
this.players.push(player);
|
||||||
this.player = player;
|
this.player = player;
|
||||||
this.thirdPersonCamera = new ThirdPersonCamera(
|
this.thirdPersonCamera = new ThirdPersonCamera(
|
||||||
@ -130,6 +138,7 @@ export class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newplayer = PlayerEntity.fromUser(user, this.renderer.scene);
|
const newplayer = PlayerEntity.fromUser(user, this.renderer.scene);
|
||||||
|
newplayer.setHeightSource(this.world);
|
||||||
this.chat.addMessage(`${user.display_name} has joined the game.`, null, {
|
this.chat.addMessage(`${user.display_name} has joined the game.`, null, {
|
||||||
color: '#fbff4e',
|
color: '#fbff4e',
|
||||||
});
|
});
|
||||||
@ -156,6 +165,7 @@ export class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newplayer = PlayerEntity.fromUser(player, this.renderer.scene);
|
const newplayer = PlayerEntity.fromUser(player, this.renderer.scene);
|
||||||
|
newplayer.setHeightSource(this.world);
|
||||||
newplayer.addUncommittedChanges(player);
|
newplayer.addUncommittedChanges(player);
|
||||||
this.players.push(newplayer);
|
this.players.push(newplayer);
|
||||||
});
|
});
|
||||||
|
@ -97,7 +97,7 @@ export class Player extends PonyEntity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (vector.y !== 0) {
|
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.changes.velocity = this.velocity.toArray();
|
||||||
|
|
||||||
this._wasMoving = true;
|
this._wasMoving = true;
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
Object3D,
|
Object3D,
|
||||||
Vector3,
|
Vector3,
|
||||||
} from 'three';
|
} from 'three';
|
||||||
|
import { ClientWorld } from './world/ClientWorld';
|
||||||
|
|
||||||
const nameTagBuilder = new CanvasUtils({
|
const nameTagBuilder = new CanvasUtils({
|
||||||
fill: false,
|
fill: false,
|
||||||
@ -33,6 +34,7 @@ export class PonyEntity {
|
|||||||
public walkAction: AnimationAction;
|
public walkAction: AnimationAction;
|
||||||
public nameTag?: NameTag;
|
public nameTag?: NameTag;
|
||||||
public changes: FullStatePacket = {};
|
public changes: FullStatePacket = {};
|
||||||
|
public heightSource?: ClientWorld;
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
this.model = (SkeletonUtils as any).clone(modelLoaderInstance.ponyModel);
|
this.model = (SkeletonUtils as any).clone(modelLoaderInstance.ponyModel);
|
||||||
@ -58,6 +60,14 @@ export class PonyEntity {
|
|||||||
this.container.rotation.z,
|
this.container.rotation.z,
|
||||||
).add(this.angularVelocity.clone().multiplyScalar(dt)),
|
).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);
|
this.mixer.update(dt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,4 +108,8 @@ export class PonyEntity {
|
|||||||
this.setColor(packet.color);
|
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.ground.position.set(0, -0.5, 0);
|
||||||
this.scene.add(this.ground);
|
//this.scene.add(this.ground);
|
||||||
|
|
||||||
this.camera.position.set(0, 4, 4);
|
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 {
|
export function clamp(x: number, min: number, max: number): number {
|
||||||
return Math.min(Math.max(x, min), max);
|
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) {
|
export function rand(randgen: () => number, min: number, max: number) {
|
||||||
return Math.floor(randgen() * (max - min + 1)) + min;
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|