starting with mapped terrains

This commit is contained in:
Evert Prants 2022-04-11 20:00:10 +03:00
parent 860084f971
commit 6618225170
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
20 changed files with 615 additions and 4 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

View File

@ -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);
});

View File

@ -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;

View File

@ -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;
}
}

View 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);
});
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@ -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);

View File

@ -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;
}

View 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;
}
}

View File

@ -0,0 +1,3 @@
export interface WorldLoader {
loadHeightMap: (chunkX: number, chunkY: number) => Promise<number[]>;
}

View 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,
);
}
}