perf: reduce mobile rendering and cull offscreen characters and building tiles

This commit is contained in:
2026-11-03 10:43:01 +09:00
parent 4b27a8c209
commit e234baa4aa
17 changed files with 1689 additions and 109 deletions
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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[],
+24 -8
View File
@@ -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
View File
@@ -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;
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}); });
+3
View File
@@ -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;
+1 -1
View File
@@ -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) {
+2 -1
View File
@@ -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();
}); });
+24
View File
@@ -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);
});
+27 -2
View File
@@ -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);