perf: reduce mobile rendering and cull offscreen characters and building tiles
This commit is contained in:
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ export const BUILDINGS = {
|
|||||||
assetManifestUrl: '/buildings/gwacheon.baked.json',
|
assetManifestUrl: '/buildings/gwacheon.baked.json',
|
||||||
assetBufferUrl: '/buildings/gwacheon.baked.bin',
|
assetBufferUrl: '/buildings/gwacheon.baked.bin',
|
||||||
assetVersion: 1,
|
assetVersion: 1,
|
||||||
|
tileSize: 1000,
|
||||||
metersPerLevel: 3,
|
metersPerLevel: 3,
|
||||||
// Visual placeholders only: not a claim about actual floors.
|
// Visual placeholders only: not a claim about actual floors.
|
||||||
fallbackHeight: { apartments: 15, residential: 9, house: 6, commercial: 12, greenhouse: 3, roof: 3, school: 12, default: 9 } as Record<string, number>,
|
fallbackHeight: { apartments: 15, residential: 9, house: 6, commercial: 12, greenhouse: 3, roof: 3, school: 12, default: 9 } as Record<string, number>,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const CHARACTER = {
|
export const CHARACTER = {
|
||||||
url: '/characters/red-sheep.glb?v=rounded-v10',
|
url: '/characters/red-sheep.glb?v=rounded-v10',
|
||||||
scale: 0.62,
|
scale: 0.62,
|
||||||
|
visibilityRadius: 3, // Includes raised arms, umbrella and idle hops around the ground anchor.
|
||||||
selfBadgeGap: 0.12,
|
selfBadgeGap: 0.12,
|
||||||
outline: 0.018,
|
outline: 0.018,
|
||||||
ink: '#352724',
|
ink: '#352724',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const RENDER_QUALITY = {
|
export const RENDER_QUALITY = {
|
||||||
|
mobileFps: 30,
|
||||||
mobileCap: 1.5,
|
mobileCap: 1.5,
|
||||||
mobilePixelBudget: 1_500_000,
|
mobilePixelBudget: 1_500_000,
|
||||||
levels: [0.75, 1, 1.25, 1.5, 1.75, 2] as readonly number[],
|
levels: [0.75, 1, 1.25, 1.5, 1.75, 2] as readonly number[],
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { type Object3D, type Raycaster, MathUtils, Vector3 } from 'three';
|
import { type Camera, type Object3D, type Raycaster, Frustum, Matrix4, MathUtils, Sphere, Vector3 } from 'three';
|
||||||
import type { Sheep } from '../scene/sheep.ts';
|
import type { Sheep } from '../scene/sheep.ts';
|
||||||
import type { PlayerState } from './protocol.ts';
|
import type { PlayerState } from './protocol.ts';
|
||||||
import { MULTIPLAYER } from '../constants/multiplayer.ts';
|
import { MULTIPLAYER } from '../constants/multiplayer.ts';
|
||||||
import { MOVEMENT } from '../constants/movement.ts';
|
import { MOVEMENT } from '../constants/movement.ts';
|
||||||
|
import { CHARACTER } from '../constants/character.ts';
|
||||||
|
|
||||||
export function createRemotePlayers(template: Sheep, scene: Object3D, heightAt: (x: number, z: number) => number) {
|
export function createRemotePlayers(template: Sheep, scene: Object3D, heightAt: (x: number, z: number) => number) {
|
||||||
const players = new Map<string, { sheep: Sheep; state: PlayerState; from: Vector3; yaw: number; elapsed: number }>();
|
const players = new Map<string, { sheep: Sheep; state: PlayerState; from: Vector3; yaw: number; elapsed: number }>();
|
||||||
|
const frustum = new Frustum(), projection = new Matrix4();
|
||||||
|
const bounds = new Sphere(new Vector3(), CHARACTER.visibilityRadius);
|
||||||
return {
|
return {
|
||||||
get(id: string) { return players.get(id)?.sheep.group; },
|
get(id: string) { return players.get(id)?.sheep.group; },
|
||||||
pick(ray: Raycaster, maxDistance: number) {
|
pick(ray: Raycaster, maxDistance: number) {
|
||||||
@@ -54,18 +57,31 @@ export function createRemotePlayers(template: Sheep, scene: Object3D, heightAt:
|
|||||||
entry.sheep.setEquipment(state.equipment.umbrella, state.equipment.raincoat, state.equipment.hood, state.equipment.flashlight);
|
entry.sheep.setEquipment(state.equipment.umbrella, state.equipment.raincoat, state.equipment.hood, state.equipment.flashlight);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update(delta: number, serverTime: number) {
|
update(delta: number, serverTime: number, camera?: Camera) {
|
||||||
|
if (camera) {
|
||||||
|
camera.updateMatrixWorld(true);
|
||||||
|
frustum.setFromProjectionMatrix(projection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse));
|
||||||
|
}
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const entry of players.values()) {
|
for (const entry of players.values()) {
|
||||||
const { sheep, state, from } = entry;
|
const { sheep, state, from } = entry;
|
||||||
const duration = MULTIPLAYER.interpolationMs / 1000;
|
const duration = MULTIPLAYER.interpolationMs / 1000;
|
||||||
const interpolating = entry.elapsed < duration;
|
const interpolating = entry.elapsed < duration;
|
||||||
entry.elapsed += Math.max(0, delta);
|
if (interpolating) {
|
||||||
const fraction = Math.min(1, entry.elapsed / duration);
|
entry.elapsed = Math.min(duration, entry.elapsed + Math.max(0, delta));
|
||||||
const x = MathUtils.lerp(from.x, state.x, fraction), z = MathUtils.lerp(from.z, state.z, fraction);
|
const fraction = Math.min(1, entry.elapsed / duration);
|
||||||
sheep.group.position.set(x, heightAt(x, z), z);
|
const x = MathUtils.lerp(from.x, state.x, fraction), z = MathUtils.lerp(from.z, state.z, fraction);
|
||||||
const angle = Math.atan2(Math.sin(state.yaw - entry.yaw), Math.cos(state.yaw - entry.yaw));
|
if (x !== sheep.group.position.x || z !== sheep.group.position.z) {
|
||||||
sheep.group.rotation.y = entry.yaw + angle * fraction;
|
sheep.group.position.set(x, heightAt(x, z), z);
|
||||||
|
}
|
||||||
|
const angle = Math.atan2(Math.sin(state.yaw - entry.yaw), Math.cos(state.yaw - entry.yaw));
|
||||||
|
sheep.group.rotation.y = entry.yaw + angle * fraction;
|
||||||
|
}
|
||||||
|
bounds.center.copy(sheep.group.position);
|
||||||
|
const visible = !camera || frustum.intersectsSphere(bounds);
|
||||||
|
changed = changed || sheep.group.visible !== visible;
|
||||||
|
sheep.group.visible = visible;
|
||||||
|
if (!visible) continue;
|
||||||
const active = sheep.pose(state.emote, Math.max(0, serverTime - state.emoteStartedAt) / 1000,
|
const active = sheep.pose(state.emote, Math.max(0, serverTime - state.emoteStartedAt) / 1000,
|
||||||
state.moving && serverTime - state.updatedAt < MULTIPLAYER.staleMovementMs, serverTime / 1000);
|
state.moving && serverTime - state.updatedAt < MULTIPLAYER.staleMovementMs, serverTime / 1000);
|
||||||
changed = changed || interpolating || active;
|
changed = changed || interpolating || active;
|
||||||
|
|||||||
+15
-11
@@ -69,7 +69,7 @@ export async function loadBuildings(): Promise<BuildingData> {
|
|||||||
|
|
||||||
export function createBuildings(data: BuildingData, terrain: Awaited<ReturnType<typeof loadTerrain>>) {
|
export function createBuildings(data: BuildingData, terrain: Awaited<ReturnType<typeof loadTerrain>>) {
|
||||||
const group = new Group();
|
const group = new Group();
|
||||||
const geometries: Record<'tagged' | 'levels' | 'fallback', ExtrudeGeometry[]> = { tagged: [], levels: [], fallback: [] };
|
const geometries = { tagged: new Map<string, ExtrudeGeometry[]>(), levels: new Map<string, ExtrudeGeometry[]>(), fallback: new Map<string, ExtrudeGeometry[]>() };
|
||||||
const counts = { tagged: 0, levels: 0, fallback: 0 };
|
const counts = { tagged: 0, levels: 0, fallback: 0 };
|
||||||
const lightSources: BuildingLightSource[] = [];
|
const lightSources: BuildingLightSource[] = [];
|
||||||
const surface = (x: number, z: number) => terrainHeightAt(terrain.metadata, terrain.heights, x, z) - terrain.metadata.observerSurfaceElevation - (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
|
const surface = (x: number, z: number) => terrainHeightAt(terrain.metadata, terrain.heights, x, z) - terrain.metadata.observerSurfaceElevation - (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
|
||||||
@@ -107,19 +107,23 @@ export function createBuildings(data: BuildingData, terrain: Awaited<ReturnType<
|
|||||||
geometry.setAttribute('buildingLightHeight', new BufferAttribute(lightHeights, 1));
|
geometry.setAttribute('buildingLightHeight', new BufferAttribute(lightHeights, 1));
|
||||||
geometry.rotateX(-Math.PI / 2);
|
geometry.rotateX(-Math.PI / 2);
|
||||||
geometry.translate(x, base, z);
|
geometry.translate(x, base, z);
|
||||||
geometries[source].push(geometry);
|
const tile = `${Math.floor(x / BUILDINGS.tileSize)},${Math.floor(z / BUILDINGS.tileSize)}`;
|
||||||
|
const bucket = geometries[source].get(tile) ?? [];
|
||||||
|
bucket.push(geometry);
|
||||||
|
geometries[source].set(tile, bucket);
|
||||||
counts[source]++;
|
counts[source]++;
|
||||||
}
|
}
|
||||||
for (const source of ['tagged', 'levels', 'fallback'] as const) {
|
for (const source of ['tagged', 'levels', 'fallback'] as const) {
|
||||||
if (!geometries[source].length) continue;
|
for (const bucket of geometries[source].values()) {
|
||||||
const merged = mergeGeometries(geometries[source], false)!;
|
const merged = mergeGeometries(bucket, false)!;
|
||||||
geometries[source].forEach(g => g.dispose());
|
bucket.forEach(g => g.dispose());
|
||||||
// Lighting uses world XZ and local height, not UVs. Retain normals and
|
// Lighting uses world XZ and local height, not UVs. Retain normals and
|
||||||
// lighting heights when sharing vertices so facade/roof seams stay sharp.
|
// lighting heights when sharing vertices so facade/roof seams stay sharp.
|
||||||
merged.deleteAttribute('uv');
|
merged.deleteAttribute('uv');
|
||||||
const indexed = indexBuildingGeometry(merged);
|
const indexed = indexBuildingGeometry(merged);
|
||||||
merged.dispose();
|
merged.dispose();
|
||||||
group.add(createBuildingMesh(source, indexed));
|
group.add(createBuildingMesh(source, indexed));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const target = structurePosition(BUILDINGS.target.latitude, BUILDINGS.target.longitude, terrain.metadata.origin);
|
const target = structurePosition(BUILDINGS.target.latitude, BUILDINGS.target.longitude, terrain.metadata.origin);
|
||||||
target.y = surface(target.x, target.z) + 20;
|
target.y = surface(target.x, target.z) + 20;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/** Pace the whole update loop while retaining real elapsed time between accepted frames. */
|
||||||
|
export function createFrameClock(fps: number) {
|
||||||
|
const interval = 1000 / fps;
|
||||||
|
let previous: number | undefined;
|
||||||
|
let next = 0;
|
||||||
|
return {
|
||||||
|
reset() { previous = undefined; next = 0; },
|
||||||
|
tick(now: number): number | undefined {
|
||||||
|
if (previous !== undefined && now + .001 < next) return;
|
||||||
|
const delta = previous === undefined ? 0 : Math.max(0, now - previous) / 1000;
|
||||||
|
next = previous === undefined || !interval ? now + interval
|
||||||
|
: next + (Math.floor(Math.max(0, now + .001 - next) / interval) + 1) * interval;
|
||||||
|
previous = now;
|
||||||
|
return delta;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+42
-23
@@ -13,6 +13,9 @@ import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
|
|||||||
import { SCENE } from '../constants/scene.ts';
|
import { SCENE } from '../constants/scene.ts';
|
||||||
import { TERRAIN } from '../constants/terrain.ts';
|
import { TERRAIN } from '../constants/terrain.ts';
|
||||||
import { LOBBY_CHARACTER, LOBBY_SUNRISE } from '../constants/lobby.ts';
|
import { LOBBY_CHARACTER, LOBBY_SUNRISE } from '../constants/lobby.ts';
|
||||||
|
import { RENDER_QUALITY } from '../constants/renderQuality.ts';
|
||||||
|
import { createFrameClock } from './frameClock.ts';
|
||||||
|
import { renderPixelRatio } from './adaptiveResolution.ts';
|
||||||
|
|
||||||
/** Same Gwanaksan DSM, summit correction, sky and solar disc as the observation scene. */
|
/** Same Gwanaksan DSM, summit correction, sky and solar disc as the observation scene. */
|
||||||
export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
||||||
@@ -60,26 +63,33 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
|||||||
foreground.add(sheep.group);
|
foreground.add(sheep.group);
|
||||||
const head = sheep.group.getObjectByName('Head')!;
|
const head = sheep.group.getObjectByName('Head')!;
|
||||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const mobile = matchMedia('(pointer: coarse)').matches;
|
||||||
|
const frameClock = createFrameClock(mobile ? RENDER_QUALITY.mobileFps : Infinity);
|
||||||
let progress = reducedMotion ? 1 : 0;
|
let progress = reducedMotion ? 1 : 0;
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
let lastFrame = 0;
|
let previousProgress = -1;
|
||||||
|
let gazeAngle = 0;
|
||||||
const render = () => {
|
const render = () => {
|
||||||
const date = new Date(LOBBY_SUNRISE.from + (LOBBY_SUNRISE.to - LOBBY_SUNRISE.from) * progress);
|
if (progress !== previousProgress) {
|
||||||
const state = calculateSun(date, observer);
|
previousProgress = progress;
|
||||||
direction.set(...directionFromAngles(state.azimuth, state.altitude));
|
const date = new Date(LOBBY_SUNRISE.from + (LOBBY_SUNRISE.to - LOBBY_SUNRISE.from) * progress);
|
||||||
sky.update(direction, state.altitude);
|
const state = calculateSun(date, observer);
|
||||||
sun.update(direction, state.altitude);
|
direction.set(...directionFromAngles(state.azimuth, state.altitude));
|
||||||
nightSky.update(date, observer, state.altitude);
|
sky.update(direction, state.altitude);
|
||||||
const moonAltitude = getMoonPosition(date, observer.latitude, observer.longitude).altitude;
|
sun.update(direction, state.altitude);
|
||||||
const gazeAltitude = MathUtils.lerp(moonAltitude, state.altitude, MathUtils.smoothstep(state.altitude, -8, 0));
|
nightSky.update(date, observer, state.altitude);
|
||||||
|
const moonAltitude = getMoonPosition(date, observer.latitude, observer.longitude).altitude;
|
||||||
|
const gazeAltitude = MathUtils.lerp(moonAltitude, state.altitude, MathUtils.smoothstep(state.altitude, -8, 0));
|
||||||
|
gazeAngle = -MathUtils.degToRad(MathUtils.clamp(gazeAltitude, 8, 28));
|
||||||
|
sheep.updateLighting(Math.max(LOBBY_CHARACTER.minimumLightingAltitude, state.altitude), 1);
|
||||||
|
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(state.altitude, -8, 15));
|
||||||
|
fog.color.copy(night).lerp(dawn, MathUtils.smoothstep(state.altitude, -8, 0));
|
||||||
|
fog.color.lerp(day, MathUtils.smoothstep(state.altitude, 0, 15));
|
||||||
|
renderer.toneMappingExposure = MathUtils.lerp(0.12, LOBBY_SUNRISE.maxExposure, MathUtils.smoothstep(state.altitude, -12, 5));
|
||||||
|
}
|
||||||
sheep.pose('idle', 0, false, elapsed / 1000);
|
sheep.pose('idle', 0, false, elapsed / 1000);
|
||||||
head.rotateX(-MathUtils.degToRad(MathUtils.clamp(gazeAltitude, 8, 28)));
|
head.rotateX(gazeAngle);
|
||||||
sheep.updateLighting(Math.max(LOBBY_CHARACTER.minimumLightingAltitude, state.altitude), 1);
|
|
||||||
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(state.altitude, -8, 15));
|
|
||||||
fog.color.copy(night).lerp(dawn, MathUtils.smoothstep(state.altitude, -8, 0));
|
|
||||||
fog.color.lerp(day, MathUtils.smoothstep(state.altitude, 0, 15));
|
|
||||||
renderer.toneMappingExposure = MathUtils.lerp(0.12, LOBBY_SUNRISE.maxExposure, MathUtils.smoothstep(state.altitude, -12, 5));
|
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
const { clientWidth: width, clientHeight: height } = canvas;
|
const { clientWidth: width, clientHeight: height } = canvas;
|
||||||
const size = Math.min(LOBBY_CHARACTER.maxSize, width * LOBBY_CHARACTER.widthFraction, height * LOBBY_CHARACTER.heightFraction);
|
const size = Math.min(LOBBY_CHARACTER.maxSize, width * LOBBY_CHARACTER.widthFraction, height * LOBBY_CHARACTER.heightFraction);
|
||||||
@@ -94,19 +104,28 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
|||||||
const { clientWidth: width, clientHeight: height } = canvas;
|
const { clientWidth: width, clientHeight: height } = canvas;
|
||||||
camera.aspect = width / Math.max(1, height);
|
camera.aspect = width / Math.max(1, height);
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
renderer.setPixelRatio(Math.min(devicePixelRatio, LOBBY_SUNRISE.maxPixelRatio));
|
renderer.setPixelRatio(renderPixelRatio(devicePixelRatio, LOBBY_SUNRISE.maxPixelRatio, width, height, mobile));
|
||||||
renderer.setSize(width, height, false);
|
renderer.setSize(width, height, false);
|
||||||
render();
|
render();
|
||||||
};
|
};
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
if (disposed) return;
|
if (disposed || document.hidden) return;
|
||||||
if (!document.hidden && lastFrame) elapsed += now - lastFrame;
|
const delta = frameClock.tick(now);
|
||||||
lastFrame = now;
|
if (delta !== undefined) {
|
||||||
progress = MathUtils.clamp((elapsed - LOBBY_SUNRISE.nightHoldMs) / LOBBY_SUNRISE.durationMs, 0, 1);
|
elapsed += delta * 1000;
|
||||||
if (!document.hidden) render();
|
progress = MathUtils.clamp((elapsed - LOBBY_SUNRISE.nightHoldMs) / LOBBY_SUNRISE.durationMs, 0, 1);
|
||||||
|
render();
|
||||||
|
}
|
||||||
if (!reducedMotion) frame = requestAnimationFrame(tick);
|
if (!reducedMotion) frame = requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
const visible = () => { lastFrame = 0; if (!document.hidden) render(); };
|
const visible = () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
frameClock.reset();
|
||||||
|
if (!document.hidden) {
|
||||||
|
render();
|
||||||
|
if (!reducedMotion) frame = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
};
|
||||||
cleanup = () => {
|
cleanup = () => {
|
||||||
cancelAnimationFrame(frame);
|
cancelAnimationFrame(frame);
|
||||||
window.removeEventListener('resize', resize);
|
window.removeEventListener('resize', resize);
|
||||||
@@ -126,7 +145,7 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
|||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
document.addEventListener('visibilitychange', visible);
|
document.addEventListener('visibilitychange', visible);
|
||||||
resize();
|
resize();
|
||||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
if (!document.hidden && progress < 1) frame = requestAnimationFrame(tick);
|
||||||
})();
|
})();
|
||||||
return { ready, dispose() { disposed = true; cleanup?.(); cleanup = undefined; } };
|
return { ready, dispose() { disposed = true; cleanup?.(); cleanup = undefined; } };
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -121,6 +121,7 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
const rigJoints = Object.fromEntries(['Head', 'ArmLeft', 'ArmRight', 'LegLeft', 'LegRight']
|
const rigJoints = Object.fromEntries(['Head', 'ArmLeft', 'ArmRight', 'LegLeft', 'LegRight']
|
||||||
.map(name => [name, group.getObjectByName(name)!]));
|
.map(name => [name, group.getObjectByName(name)!]));
|
||||||
const joints = rigJoints;
|
const joints = rigJoints;
|
||||||
|
const jointEntries = Object.entries(joints);
|
||||||
const rigCoatParts = coatParts.map(part => copy(part)!);
|
const rigCoatParts = coatParts.map(part => copy(part)!);
|
||||||
const rigHood = copy(hood), rigFoldedHood = copy(foldedHood), rigUmbrella = copy(gear?.umbrella);
|
const rigHood = copy(hood), rigFoldedHood = copy(foldedHood), rigUmbrella = copy(gear?.umbrella);
|
||||||
const rigFlashlight = copy(gear?.flashlight);
|
const rigFlashlight = copy(gear?.flashlight);
|
||||||
@@ -233,7 +234,7 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
|| previousUmbrellaPose !== umbrellaPose || umbrellaPose !== Number(umbrellaOpen);
|
|| previousUmbrellaPose !== umbrellaPose || umbrellaPose !== Number(umbrellaOpen);
|
||||||
previousMoving = moving;
|
previousMoving = moving;
|
||||||
previousEmote = emote;
|
previousEmote = emote;
|
||||||
for (const [name, joint] of Object.entries(joints)) joint.quaternion.copy(rest[name]);
|
for (const [name, joint] of jointEntries) joint.quaternion.copy(rest[name]);
|
||||||
joints.LegLeft.position.copy(legPositions[0]);
|
joints.LegLeft.position.copy(legPositions[0]);
|
||||||
joints.LegRight.position.copy(legPositions[1]);
|
joints.LegRight.position.copy(legPositions[1]);
|
||||||
if (emote === 'wave') {
|
if (emote === 'wave') {
|
||||||
|
|||||||
+41
-14
@@ -31,6 +31,9 @@ import { COAST } from '../constants/coast.ts';
|
|||||||
import { GISAMUN } from '../constants/gisamun.ts';
|
import { GISAMUN } from '../constants/gisamun.ts';
|
||||||
import { createHarbour, isGisamun } from './harbour.ts';
|
import { createHarbour, isGisamun } from './harbour.ts';
|
||||||
import { createRenderInvalidation } from './renderInvalidation.ts';
|
import { createRenderInvalidation } from './renderInvalidation.ts';
|
||||||
|
import { createFrameClock } from './frameClock.ts';
|
||||||
|
import { renderPixelRatio } from './adaptiveResolution.ts';
|
||||||
|
import { RENDER_QUALITY } from '../constants/renderQuality.ts';
|
||||||
import { createTerrainMinimap } from '../ui/terrainMinimap.ts';
|
import { createTerrainMinimap } from '../ui/terrainMinimap.ts';
|
||||||
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
|
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
|
||||||
import { createSummitWalking } from '../controls/summitWalking.ts';
|
import { createSummitWalking } from '../controls/summitWalking.ts';
|
||||||
@@ -80,6 +83,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
let sharedNow: (() => number) | null = null;
|
let sharedNow: (() => number) | null = null;
|
||||||
let playerMoving = false;
|
let playerMoving = false;
|
||||||
const profiling = import.meta.env.DEV && new URLSearchParams(location.search).has('profile');
|
const profiling = import.meta.env.DEV && new URLSearchParams(location.search).has('profile');
|
||||||
|
const mobile = matchMedia('(pointer: coarse)').matches;
|
||||||
|
const frameClock = createFrameClock(mobile && !profiling ? RENDER_QUALITY.mobileFps : Infinity);
|
||||||
let profile: ReturnType<typeof createRenderProfile> | undefined;
|
let profile: ReturnType<typeof createRenderProfile> | undefined;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
const summitStarted = performance.now();
|
const summitStarted = performance.now();
|
||||||
@@ -171,6 +176,17 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
const destinationRay = new Raycaster();
|
const destinationRay = new Raycaster();
|
||||||
const pointer = new Vector2();
|
const pointer = new Vector2();
|
||||||
let cameraDistance: number = CHARACTER.cameraDistance;
|
let cameraDistance: number = CHARACTER.cameraDistance;
|
||||||
|
const followPosition = new Vector3();
|
||||||
|
const followRotation = camera.quaternion.clone();
|
||||||
|
let previousCameraDistance = -1;
|
||||||
|
const updateFollowCamera = () => {
|
||||||
|
if (previousCameraDistance === cameraDistance && followPosition.equals(camera.position)
|
||||||
|
&& followRotation.equals(camera.quaternion)) return;
|
||||||
|
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
|
||||||
|
followPosition.copy(camera.position);
|
||||||
|
followRotation.copy(camera.quaternion);
|
||||||
|
previousCameraDistance = cameraDistance;
|
||||||
|
};
|
||||||
let selectedCompanion: string | undefined;
|
let selectedCompanion: string | undefined;
|
||||||
let companionAnchor: ReturnType<typeof createCharacterOverlay> | undefined;
|
let companionAnchor: ReturnType<typeof createCharacterOverlay> | undefined;
|
||||||
const directionButton = document.querySelector<HTMLButtonElement>('#direction-toggle')!;
|
const directionButton = document.querySelector<HTMLButtonElement>('#direction-toggle')!;
|
||||||
@@ -180,7 +196,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
const pickGround = (x: number, y: number) => {
|
const pickGround = (x: number, y: number) => {
|
||||||
const bounds = canvas.getBoundingClientRect();
|
const bounds = canvas.getBoundingClientRect();
|
||||||
pointer.set((x - bounds.left) / bounds.width * 2 - 1, -(y - bounds.top) / bounds.height * 2 + 1);
|
pointer.set((x - bounds.left) / bounds.width * 2 - 1, -(y - bounds.top) / bounds.height * 2 + 1);
|
||||||
if (sheep) updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
|
if (sheep) updateFollowCamera();
|
||||||
const view = sheep ? followCamera : camera;
|
const view = sheep ? followCamera : camera;
|
||||||
view.updateMatrixWorld(true);
|
view.updateMatrixWorld(true);
|
||||||
destinationRay.setFromCamera(pointer, view);
|
destinationRay.setFromCamera(pointer, view);
|
||||||
@@ -372,7 +388,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
overviewCamera.updateProjectionMatrix();
|
overviewCamera.updateProjectionMatrix();
|
||||||
followCamera.aspect = camera.aspect;
|
followCamera.aspect = camera.aspect;
|
||||||
followCamera.updateProjectionMatrix();
|
followCamera.updateProjectionMatrix();
|
||||||
renderer.setPixelRatio(profile?.forcedDpr ?? Math.min(devicePixelRatio, SCENE.maxPixelRatio));
|
renderer.setPixelRatio(profile?.forcedDpr ?? renderPixelRatio(devicePixelRatio,
|
||||||
|
mobile ? RENDER_QUALITY.mobileCap : SCENE.maxPixelRatio, innerWidth, innerHeight, mobile));
|
||||||
renderer.setSize(innerWidth, innerHeight);
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
composer?.setPixelRatio(renderer.getPixelRatio());
|
composer?.setPixelRatio(renderer.getPixelRatio());
|
||||||
composer?.setSize(innerWidth, innerHeight);
|
composer?.setSize(innerWidth, innerHeight);
|
||||||
@@ -380,13 +397,13 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
};
|
};
|
||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
resize();
|
resize();
|
||||||
let previousTime = performance.now();
|
|
||||||
const forward = new Vector3();
|
const forward = new Vector3();
|
||||||
const travel = new Vector3();
|
const travel = new Vector3();
|
||||||
renderer.setAnimationLoop((now) => {
|
const renderFrame = (now: number) => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
const delta = frameClock.tick(now);
|
||||||
|
if (delta === undefined) return;
|
||||||
const cpuStart = profile?.active() ? performance.now() : 0;
|
const cpuStart = profile?.active() ? performance.now() : 0;
|
||||||
const delta = (now - previousTime) / 1000;
|
|
||||||
previousTime = now;
|
|
||||||
const replayPosition = profile?.step(now);
|
const replayPosition = profile?.step(now);
|
||||||
let moving = false;
|
let moving = false;
|
||||||
let turning = false;
|
let turning = false;
|
||||||
@@ -426,7 +443,6 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
playerMoving = moving;
|
playerMoving = moving;
|
||||||
if (remotePlayers?.update(delta, sharedNow ? sharedNow() : Date.now())) rendering.invalidate();
|
|
||||||
ocean?.animate(now / 1000);
|
ocean?.animate(now / 1000);
|
||||||
let weatherChanged = false;
|
let weatherChanged = false;
|
||||||
const blend = 1 - Math.exp(-Math.min(delta, 0.1) / WEATHER.transitionSeconds);
|
const blend = 1 - Math.exp(-Math.min(delta, 0.1) / WEATHER.transitionSeconds);
|
||||||
@@ -451,9 +467,10 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
faceTravel(sheep.group, travel, delta);
|
faceTravel(sheep.group, travel, delta);
|
||||||
}
|
}
|
||||||
if (sheep.pose(emotes.current, emotes.elapsed, moving, now / 1000)) rendering.invalidate();
|
if (sheep.pose(emotes.current, emotes.elapsed, moving, now / 1000)) rendering.invalidate();
|
||||||
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
|
updateFollowCamera();
|
||||||
}
|
}
|
||||||
const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera;
|
const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera;
|
||||||
|
if (remotePlayers?.update(delta, sharedNow ? sharedNow() : Date.now(), activeCamera)) rendering.invalidate();
|
||||||
companionAnchor?.update(activeCamera, !overview);
|
companionAnchor?.update(activeCamera, !overview);
|
||||||
flashlight?.update(flashlightOn && !overview);
|
flashlight?.update(flashlightOn && !overview);
|
||||||
const precipitating = precipitation.update(delta, activeCamera.position, atmosphere.rain, atmosphere.snowfall,
|
const precipitating = precipitation.update(delta, activeCamera.position, atmosphere.rain, atmosphere.snowfall,
|
||||||
@@ -475,7 +492,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
bloom.threshold = torchActive ? FLASHLIGHT.bloomThreshold : BUILDINGS.lighting.bloomThreshold;
|
bloom.threshold = torchActive ? FLASHLIGHT.bloomThreshold : BUILDINGS.lighting.bloomThreshold;
|
||||||
bloom.radius = torchActive ? FLASHLIGHT.bloomRadius : BUILDINGS.lighting.bloomRadius;
|
bloom.radius = torchActive ? FLASHLIGHT.bloomRadius : BUILDINGS.lighting.bloomRadius;
|
||||||
}
|
}
|
||||||
if (composer && !profile?.disablePost) {
|
if (composer && bloom?.enabled && !profile?.disablePost) {
|
||||||
renderPass.camera = activeCamera;
|
renderPass.camera = activeCamera;
|
||||||
composer.render(delta);
|
composer.render(delta);
|
||||||
} else renderer.render(scene, activeCamera);
|
} else renderer.render(scene, activeCamera);
|
||||||
@@ -483,12 +500,21 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
if (cpuStart) profile?.end(now, { update: updateEnd - cpuStart, minimap: minimapEnd - updateEnd,
|
if (cpuStart) profile?.end(now, { update: updateEnd - cpuStart, minimap: minimapEnd - updateEnd,
|
||||||
submit: performance.now() - minimapEnd });
|
submit: performance.now() - minimapEnd });
|
||||||
if (profile?.observe(now, true)) resize();
|
if (profile?.observe(now, true)) resize();
|
||||||
});
|
};
|
||||||
|
const onVisibility = () => {
|
||||||
|
frameClock.reset();
|
||||||
|
rendering.invalidate();
|
||||||
|
renderer.setAnimationLoop(document.hidden ? null : renderFrame);
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
onVisibility();
|
||||||
const world = {
|
const world = {
|
||||||
buildingCounts: buildings?.counts,
|
buildingCounts: buildings?.counts,
|
||||||
captureFrame(view?: PerspectiveCamera) {
|
captureFrame(view?: PerspectiveCamera) {
|
||||||
const activeCamera = view ?? (overview ? overviewCamera : sheep ? followCamera : camera);
|
const activeCamera = view ?? (overview ? overviewCamera : sheep ? followCamera : camera);
|
||||||
if (composer && !profile?.disablePost) {
|
remotePlayers?.update(0, sharedNow ? sharedNow() : Date.now(), activeCamera);
|
||||||
|
rendering.invalidate();
|
||||||
|
if (composer && bloom?.enabled && !profile?.disablePost) {
|
||||||
renderPass.camera = activeCamera;
|
renderPass.camera = activeCamera;
|
||||||
composer.render(0);
|
composer.render(0);
|
||||||
} else renderer.render(scene, activeCamera);
|
} else renderer.render(scene, activeCamera);
|
||||||
@@ -584,14 +610,14 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
camera.lookAt(camera.position.clone().add(forward));
|
camera.lookAt(camera.position.clone().add(forward));
|
||||||
if (sheep) {
|
if (sheep) {
|
||||||
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
|
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
|
||||||
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
|
updateFollowCamera();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
lookAtSun() {
|
lookAtSun() {
|
||||||
if (!solarState) return;
|
if (!solarState) return;
|
||||||
restoreView();
|
restoreView();
|
||||||
camera.lookAt(camera.position.clone().add(new Vector3(...directionFromAngles(solarState.azimuth, 0))));
|
camera.lookAt(camera.position.clone().add(new Vector3(...directionFromAngles(solarState.azimuth, 0))));
|
||||||
if (sheep) updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
|
if (sheep) updateFollowCamera();
|
||||||
},
|
},
|
||||||
setEyeHeight(meters: number) {
|
setEyeHeight(meters: number) {
|
||||||
eyeHeight = meters;
|
eyeHeight = meters;
|
||||||
@@ -622,6 +648,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
closeCompanion();
|
closeCompanion();
|
||||||
profile?.dispose();
|
profile?.dispose();
|
||||||
renderer.setAnimationLoop(null);
|
renderer.setAnimationLoop(null);
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
minimap.dispose();
|
minimap.dispose();
|
||||||
compass.dispose();
|
compass.dispose();
|
||||||
selfBadge?.dispose();
|
selfBadge?.dispose();
|
||||||
@@ -652,7 +679,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
if (import.meta.env.DEV && profiling) void import('../dev/renderProfile.ts').then(({ createRenderProfile }) => {
|
if (import.meta.env.DEV && profiling) void import('../dev/renderProfile.ts').then(({ createRenderProfile }) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
profile = createRenderProfile(renderer, scene, camera, () => world.resetPosition(), target, () =>
|
profile = createRenderProfile(renderer, scene, camera, () => world.resetPosition(), target, () =>
|
||||||
composer && !profile?.disablePost ? composer.passes.filter(pass => pass.enabled).map(pass => pass.constructor.name) : ['WebGLRenderer'], resize);
|
composer && bloom?.enabled && !profile?.disablePost ? composer.passes.filter(pass => pass.enabled).map(pass => pass.constructor.name) : ['WebGLRenderer'], resize);
|
||||||
renderer.info.autoReset = false;
|
renderer.info.autoReset = false;
|
||||||
resize();
|
resize();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export function createCompass() {
|
|||||||
const scale = root.querySelector<HTMLElement>('.compass-scale')!;
|
const scale = root.querySelector<HTMLElement>('.compass-scale')!;
|
||||||
const bearing = root.querySelector<HTMLElement>('.compass-bearing')!;
|
const bearing = root.querySelector<HTMLElement>('.compass-bearing')!;
|
||||||
const direction = new Vector3();
|
const direction = new Vector3();
|
||||||
|
let previousHeading: number | undefined;
|
||||||
const ticks = Array.from({ length: 360 / COMPASS.tickDegrees }, (_, index) => {
|
const ticks = Array.from({ length: 360 / COMPASS.tickDegrees }, (_, index) => {
|
||||||
const degrees = index * COMPASS.tickDegrees;
|
const degrees = index * COMPASS.tickDegrees;
|
||||||
const tick = document.createElement('span');
|
const tick = document.createElement('span');
|
||||||
@@ -19,6 +20,8 @@ export function createCompass() {
|
|||||||
update(camera: Camera) {
|
update(camera: Camera) {
|
||||||
camera.getWorldDirection(direction);
|
camera.getWorldDirection(direction);
|
||||||
const heading = (Math.atan2(direction.x, -direction.z) * 180 / Math.PI + 360) % 360;
|
const heading = (Math.atan2(direction.x, -direction.z) * 180 / Math.PI + 360) % 360;
|
||||||
|
if (heading === previousHeading) return;
|
||||||
|
previousHeading = heading;
|
||||||
for (const { tick, degrees } of ticks) {
|
for (const { tick, degrees } of ticks) {
|
||||||
const offset = (degrees - heading + 540) % 360 - 180;
|
const offset = (degrees - heading + 540) % 360 - 180;
|
||||||
tick.hidden = Math.abs(offset) > COMPASS.visibleDegrees / 2;
|
tick.hidden = Math.abs(offset) > COMPASS.visibleDegrees / 2;
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export class TimeController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private sync = () => {
|
private sync = () => {
|
||||||
if (this.mode === 'LIVE') this.render(this.sharedClock ? this.sharedClock() : new Date());
|
if (!document.hidden && this.mode === 'LIVE') this.render(this.sharedClock ? this.sharedClock() : new Date());
|
||||||
};
|
};
|
||||||
|
|
||||||
private render(date: Date) {
|
private render(date: Date) {
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ test('downloaded footprints are deduplicated, closed, and generate finite merged
|
|||||||
const city=createBuildings(data,terrain);
|
const city=createBuildings(data,terrain);
|
||||||
assert.equal(Object.values(city.counts).reduce((a,b)=>a+b,0),data.features.length);
|
assert.equal(Object.values(city.counts).reduce((a,b)=>a+b,0),data.features.length);
|
||||||
assert.ok(city.counts.tagged>0 && city.counts.levels>0 && city.counts.fallback>0);
|
assert.ok(city.counts.tagged>0 && city.counts.levels>0 && city.counts.fallback>0);
|
||||||
assert.ok(city.group.children.length<=3);
|
assert.ok(city.group.children.length > 3, 'separate city districts can be culled independently');
|
||||||
|
assert.ok(city.group.children.length < 100, 'batch buildings instead of one draw call per building');
|
||||||
for(const mesh of city.group.children as Mesh[]) assert.ok(Array.from(mesh.geometry.attributes.position.array).every(Number.isFinite));
|
for(const mesh of city.group.children as Mesh[]) assert.ok(Array.from(mesh.geometry.attributes.position.array).every(Number.isFinite));
|
||||||
city.dispose();
|
city.dispose();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createFrameClock } from '../src/scene/frameClock.ts';
|
||||||
|
|
||||||
|
test('30 fps pacing retains elapsed simulation time on 60 and 120 Hz displays', () => {
|
||||||
|
for (const hz of [60, 120]) {
|
||||||
|
const clock = createFrameClock(30);
|
||||||
|
let frames = 0, elapsed = 0;
|
||||||
|
for (let i = 0; i <= hz * 10; i++) {
|
||||||
|
const delta = clock.tick(i * 1000 / hz);
|
||||||
|
if (delta !== undefined) { frames++; elapsed += delta; }
|
||||||
|
}
|
||||||
|
assert.equal(frames, 301);
|
||||||
|
assert.ok(Math.abs(elapsed - 10) < 1e-8);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resume resets elapsed time instead of simulating the hidden interval', () => {
|
||||||
|
const clock = createFrameClock(30);
|
||||||
|
clock.tick(0); clock.tick(100);
|
||||||
|
clock.reset();
|
||||||
|
assert.equal(clock.tick(60000), 0);
|
||||||
|
assert.equal(clock.tick(60001), undefined);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { BoxGeometry, Group, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three';
|
import { BoxGeometry, Group, Mesh, MeshBasicMaterial, PerspectiveCamera, Raycaster, Vector3 } from 'three';
|
||||||
import { createServerClock } from '../src/multiplayer/clock.ts';
|
import { createServerClock } from '../src/multiplayer/clock.ts';
|
||||||
import { createRemotePlayers } from '../src/multiplayer/remotePlayers.ts';
|
import { createRemotePlayers } from '../src/multiplayer/remotePlayers.ts';
|
||||||
import type { Sheep } from '../src/scene/sheep.ts';
|
import type { Sheep } from '../src/scene/sheep.ts';
|
||||||
@@ -16,6 +16,27 @@ test('server clock uses round trip midpoint and monotonic elapsed time', () => {
|
|||||||
assert.equal(clock.now(), 1001020);
|
assert.equal(clock.now(), 1001020);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('offscreen players skip posing but keep movement current and reappear when the camera turns', () => {
|
||||||
|
const scene = new Group(), camera = new PerspectiveCamera(60, 1, .1, 100);
|
||||||
|
let poses = 0;
|
||||||
|
const template = { group: new Group(), clone() { return { group: new Group(), setEquipment() {},
|
||||||
|
pose() { poses++; return true; }, dispose() {} } as unknown as Sheep; } } as Sheep;
|
||||||
|
const remote = createRemotePlayers(template, scene, () => 0);
|
||||||
|
const player: PlayerState = { id: 'other', nickname: '친구', x: 0, z: 10, yaw: 0, moving: false,
|
||||||
|
emote: 'idle', equipment: { umbrella: false, raincoat: false, hood: false }, updatedAt: 1000, emoteStartedAt: 1000 };
|
||||||
|
remote.synchronize([player], 'self');
|
||||||
|
for (let i = 0; i < 300; i++) remote.update(1 / 30, 1000 + i * 1000 / 30, camera);
|
||||||
|
assert.equal(poses, 0);
|
||||||
|
assert.equal(remote.get('other')!.visible, false);
|
||||||
|
remote.synchronize([{ ...player, x: 2, updatedAt: 11000 }], 'self');
|
||||||
|
remote.update(.2, 11200, camera);
|
||||||
|
assert.equal(remote.get('other')!.position.x, 2);
|
||||||
|
camera.lookAt(2, 0, 10);
|
||||||
|
assert.equal(remote.update(.03, 11230, camera), true);
|
||||||
|
assert.equal(poses, 1);
|
||||||
|
assert.equal(remote.get('other')!.visible, true);
|
||||||
|
});
|
||||||
|
|
||||||
test('companion selection picks visible players and chooses the nearest reachable unoccupied side', () => {
|
test('companion selection picks visible players and chooses the nearest reachable unoccupied side', () => {
|
||||||
const scene = new Group();
|
const scene = new Group();
|
||||||
const geometry = new BoxGeometry(1, 2, 1), material = new MeshBasicMaterial();
|
const geometry = new BoxGeometry(1, 2, 1), material = new MeshBasicMaterial();
|
||||||
@@ -51,7 +72,8 @@ test('remote snapshot reconciles membership, equipment and interpolated motion w
|
|||||||
let disposed = 0;
|
let disposed = 0;
|
||||||
const template = { clone() { return { group: new Group(), setEquipment: (...v: boolean[]) => equipment.push(v),
|
const template = { clone() { return { group: new Group(), setEquipment: (...v: boolean[]) => equipment.push(v),
|
||||||
pose: () => false, dispose() { this.group.removeFromParent(); disposed++; } } as unknown as Sheep; } } as Sheep;
|
pose: () => false, dispose() { this.group.removeFromParent(); disposed++; } } as unknown as Sheep; } } as Sheep;
|
||||||
const remote = createRemotePlayers(template, scene, () => 3);
|
let heightSamples = 0;
|
||||||
|
const remote = createRemotePlayers(template, scene, () => { heightSamples++; return 3; });
|
||||||
const player: PlayerState = { id: 'other', nickname: '방문자', x: 0, z: 0, yaw: 0, moving: false,
|
const player: PlayerState = { id: 'other', nickname: '방문자', x: 0, z: 0, yaw: 0, moving: false,
|
||||||
emote: 'idle', equipment: { umbrella: true, raincoat: true, hood: false, flashlight: true }, updatedAt: 1000, emoteStartedAt: 1000 };
|
emote: 'idle', equipment: { umbrella: true, raincoat: true, hood: false, flashlight: true }, updatedAt: 1000, emoteStartedAt: 1000 };
|
||||||
remote.synchronize([player, { ...player, id: 'self' }], 'self');
|
remote.synchronize([player, { ...player, id: 'self' }], 'self');
|
||||||
@@ -63,6 +85,9 @@ test('remote snapshot reconciles membership, equipment and interpolated motion w
|
|||||||
remote.update(.2, 1300);
|
remote.update(.2, 1300);
|
||||||
assert.equal(scene.children[0].position.x, 1);
|
assert.equal(scene.children[0].position.x, 1);
|
||||||
assert.equal(scene.children[0].position.y, 3);
|
assert.equal(scene.children[0].position.y, 3);
|
||||||
|
const samplesAfterMoving = heightSamples;
|
||||||
|
for (let i = 0; i < 300; i++) remote.update(1 / 30, 1300 + i * 1000 / 30);
|
||||||
|
assert.equal(heightSamples, samplesAfterMoving, 'stationary players reuse their terrain height');
|
||||||
remote.synchronize([], 'self');
|
remote.synchronize([], 'self');
|
||||||
assert.equal(scene.children.length, 0);
|
assert.equal(scene.children.length, 0);
|
||||||
assert.equal(disposed, 1);
|
assert.equal(disposed, 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user