Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 110 additions & 13 deletions client/src/chunks/ChunkManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Vector2, Vector3, Vector3Like } from 'three';
import Chunk from './Chunk';
import { BatchId, ChunkId } from './ChunkConstants';
import { BATCH_WORLD_SIZE, BatchId, ChunkId } from './ChunkConstants';
import ChunkRegistry from './ChunkRegistry';
import ChunkStats from './ChunkStats';
import { BlockId, WATER_SURFACE_Y_OFFSET } from '../blocks/BlockConstants';
Expand All @@ -26,13 +26,22 @@ import {

// Working variables
const fromVec2 = new Vector2();
const toVec2 = new Vector2();
const vec1 = new Vector3();
const vec2 = new Vector3();
const HALF_BATCH_WORLD_SIZE = BATCH_WORLD_SIZE / 2;
const VISIBILITY_CELL_SIZE = BATCH_WORLD_SIZE / 4;
const VIEW_DISTANCE_SQUARED_EPSILON = 0.0001;

export default class ChunkManager {
private _game: Game;
private _registry: ChunkRegistry = new ChunkRegistry();
private _firstChunkBatchBuilt: boolean = false;
private _visibleBatchIds: Set<BatchId> = new Set();
private _lastVisibilityCellX: number | null = null;
private _lastVisibilityCellZ: number | null = null;
private _lastViewDistanceSquared: number = -1;
private _wasViewDistanceEnabled: boolean | null = null;

public constructor(game: Game) {
this._game = game;
Expand Down Expand Up @@ -68,24 +77,35 @@ export default class ChunkManager {
private _onAnimate = (_payload: RendererEventPayload.IAnimate): void => {
ChunkStats.reset();

// Distance View feature: Reduces rendering load by making distant batches invisible.
// Optimization hints for future improvements: Calculating the distance between all
// batches and the camera every frame might be costly. Consider recalculating only
// when camera or batch information changes, using a partitioning approach like
// Octree, or spreading batch checks across multiple frames.
if (!this._game.settingsManager.qualityPerfTradeoff.viewDistance.enabled) {
// When view distance is disabled, ensure all batch meshes are in the scene
this._game.chunkMeshManager.addAllBatchMeshesToScene();
const viewDistanceEnabled = this._game.settingsManager.qualityPerfTradeoff.viewDistance.enabled;
if (!viewDistanceEnabled) {
if (this._wasViewDistanceEnabled !== false) {
this._game.chunkMeshManager.addAllBatchMeshesToScene();
this._visibleBatchIds.clear();
}
this._wasViewDistanceEnabled = false;
ChunkStats.visibleCount = this._game.chunkMeshManager.batchCount;
return;
}

const viewDistance = this._game.renderer.viewDistance;
const viewDistanceSquared = viewDistance * viewDistance;
const cameraPos = this._game.camera.activeCamera.position;
const cellX = this._toVisibilityCellCoordinate(cameraPos.x);
const cellZ = this._toVisibilityCellCoordinate(cameraPos.z);
const viewDistanceChanged = Math.abs(this._lastViewDistanceSquared - viewDistanceSquared) > VIEW_DISTANCE_SQUARED_EPSILON;
const cellChanged = this._lastVisibilityCellX !== cellX || this._lastVisibilityCellZ !== cellZ;
const modeChanged = this._wasViewDistanceEnabled !== true;

if (modeChanged || viewDistanceChanged || cellChanged) {
this._refreshVisibleBatches(fromVec2.set(cameraPos.x, cameraPos.z), viewDistanceSquared, modeChanged);
this._lastVisibilityCellX = cellX;
this._lastVisibilityCellZ = cellZ;
this._lastViewDistanceSquared = viewDistanceSquared;
}

// Distance is calculated ignoring the Y-axis (Up direction) to process distant batches
// without regard to elevation, aiming for a more natural appearance.
this._game.chunkMeshManager.applyBatchViewDistance(fromVec2.set(cameraPos.x, cameraPos.z), viewDistanceSquared);
this._wasViewDistanceEnabled = true;
ChunkStats.visibleCount = this._visibleBatchIds.size;
}

private _onBlocksPacket = (payload: NetworkManagerEventPayload.IBlocksPacket) => {
Expand Down Expand Up @@ -180,6 +200,7 @@ export default class ChunkManager {
if (chunkIds.length === 0) {
// Batch is now empty, remove its meshes
this._game.chunkMeshManager.removeAllBatchMeshes(batchId);
this._visibleBatchIds.delete(batchId);
return;
}

Expand All @@ -201,6 +222,7 @@ export default class ChunkManager {
if (validChunkIds.length === 0) {
// All chunks in batch have been removed, clean up batch meshes
this._game.chunkMeshManager.removeAllBatchMeshes(batchId);
this._visibleBatchIds.delete(batchId);
return;
}

Expand Down Expand Up @@ -229,6 +251,8 @@ export default class ChunkManager {
this._game.chunkMeshManager.removeBatchTransparentSolidMesh(batchId);
}

this._syncBatchVisibility(batchId);

// Update batch metadata
this._registry.updateBatchMetadata(batchId, {
blockCount,
Expand Down Expand Up @@ -279,4 +303,77 @@ export default class ChunkManager {
const absWorldPositionY = Math.abs(worldPosition.y);
return absWorldPositionY - Math.floor(absWorldPositionY) < 1.0 + WATER_SURFACE_Y_OFFSET;
}
}

private _toVisibilityCellCoordinate(value: number): number {
return Math.floor(value / VISIBILITY_CELL_SIZE);
}

// Distance is calculated ignoring the Y-axis (Up direction) to process distant batches
// without regard to elevation, aiming for a more natural appearance.
private _isBatchInRange(batchId: BatchId, fromVec2: Vector2, viewDistanceSquared: number): boolean {
const batchOrigin = Chunk.batchIdToBatchOrigin(batchId);
return fromVec2.distanceToSquared(
toVec2.set(
batchOrigin.x + HALF_BATCH_WORLD_SIZE,
batchOrigin.z + HALF_BATCH_WORLD_SIZE,
),
) <= viewDistanceSquared;
}

private _refreshVisibleBatches(fromVec2: Vector2, viewDistanceSquared: number, forceApplyAll: boolean): void {
const nextVisibleBatchIds: Set<BatchId> = new Set();

for (const batchId of this._game.chunkMeshManager.batchIds) {
const inRange = this._isBatchInRange(batchId, fromVec2, viewDistanceSquared);
if (inRange) {
nextVisibleBatchIds.add(batchId);
}

if (forceApplyAll) {
this._game.chunkMeshManager.setBatchInScene(batchId, inRange);
}
}

if (!forceApplyAll) {
for (const batchId of this._visibleBatchIds) {
if (!nextVisibleBatchIds.has(batchId)) {
this._game.chunkMeshManager.setBatchInScene(batchId, false);
}
}

for (const batchId of nextVisibleBatchIds) {
if (!this._visibleBatchIds.has(batchId)) {
this._game.chunkMeshManager.setBatchInScene(batchId, true);
}
}
}

this._visibleBatchIds = nextVisibleBatchIds;
}

// Newly built batches should be immediately synchronized so they don't wait for the
// next visibility cell transition.
private _syncBatchVisibility(batchId: BatchId): void {
if (!this._game.chunkMeshManager.hasBatch(batchId)) {
this._visibleBatchIds.delete(batchId);
return;
}

if (!this._game.settingsManager.qualityPerfTradeoff.viewDistance.enabled) {
this._game.chunkMeshManager.setBatchInScene(batchId, true);
this._visibleBatchIds.delete(batchId);
return;
}

const cameraPos = this._game.camera.activeCamera.position;
const viewDistance = this._game.renderer.viewDistance;
const inRange = this._isBatchInRange(batchId, fromVec2.set(cameraPos.x, cameraPos.z), viewDistance * viewDistance);

this._game.chunkMeshManager.setBatchInScene(batchId, inRange);
if (inRange) {
this._visibleBatchIds.add(batchId);
} else {
this._visibleBatchIds.delete(batchId);
}
}
}
49 changes: 30 additions & 19 deletions client/src/chunks/ChunkMeshManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ export default class ChunkMeshManager {
this._game = game;
}

public get batchIds(): IterableIterator<BatchId> {
return this._batchIds.values();
}

public get batchCount(): number {
return this._batchIds.size;
}

public hasBatch(batchId: BatchId): boolean {
return this._batchIds.has(batchId);
}

private _createOrUpdateMesh(id: BatchId, data: BlocksBufferGeometryData, cache: Map<BatchId, Mesh>, material: Material): Mesh {
const { positions, normals, uvs, indices, colors, lightLevels, foamLevels, foamLevelsDiag } = data;

Expand Down Expand Up @@ -229,6 +241,22 @@ export default class ChunkMeshManager {
}
}

public setBatchInScene(batchId: BatchId, inScene: boolean): void {
const liquidMesh = this._batchLiquidMeshes.get(batchId);
const opaqueSolidMesh = this._batchOpaqueSolidMeshes.get(batchId);
const transparentSolidMesh = this._batchTransparentSolidMeshes.get(batchId);

if (liquidMesh) {
this._setMeshInScene(liquidMesh, inScene);
}
if (opaqueSolidMesh) {
this._setMeshInScene(opaqueSolidMesh, inScene);
}
if (transparentSolidMesh) {
this._setMeshInScene(transparentSolidMesh, inScene);
}
}

public get solidMeshesInScene(): Mesh<BufferGeometry, MeshBasicMaterial>[] {
if (this._solidMeshesInSceneDirty) {
this._solidMeshesInScene.length = 0;
Expand Down Expand Up @@ -257,25 +285,8 @@ export default class ChunkMeshManager {

public addAllBatchMeshesToScene(): void {
for (const batchId of this._batchIds) {
const liquidMesh = this._batchLiquidMeshes.get(batchId);
const opaqueSolidMesh = this._batchOpaqueSolidMeshes.get(batchId);
const transparentSolidMesh = this._batchTransparentSolidMeshes.get(batchId);

if (!liquidMesh && !opaqueSolidMesh && !transparentSolidMesh) {
continue;
}

if (liquidMesh) {
this._setMeshInScene(liquidMesh, true);
}
if (opaqueSolidMesh) {
this._setMeshInScene(opaqueSolidMesh, true);
}
if (transparentSolidMesh) {
this._setMeshInScene(transparentSolidMesh, true);
}

this.setBatchInScene(batchId, true);
ChunkStats.visibleCount++;
}
}
}
}
53 changes: 41 additions & 12 deletions client/src/core/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ import type { NetworkManagerEventPayload } from '../network/NetworkManager';
import { type ClientSettingsEventPayload, ClientSettingsEventType } from '../settings/SettingsManager';

const MISSING_SKYBOX_TEXTURE_PATH = '/textures/missing-skybox';
// Cap internal render target pixel count to avoid severe fullscreen slowdowns on
// high-DPI displays (e.g. Retina). Windowed mode remains sharper because viewport
// area is smaller and usually falls below this budget.
const MAX_RENDER_TARGET_PIXELS = 2560 * 1440;
const MIN_RENDER_PIXEL_RATIO = 0.5;

// Working variables
const color = new Color();
Expand Down Expand Up @@ -203,6 +208,34 @@ export default class Renderer {
public get viewDistance(): number { return Math.min(this._game.settingsManager.qualityPerfTradeoff.viewDistance.distance, this._fogFar); }
public get webGLRenderer(): WebGLRenderer { return this._renderer; }

private _getViewportSize(): { width: number; height: number } {
return {
width: Math.max(1, document.documentElement.clientWidth),
height: Math.max(1, document.documentElement.clientHeight),
};
}

private _calculateEffectivePixelRatio(): number {
const resolutionMultiplier = this._game.settingsManager.qualityPerfTradeoff.resolution.multiplier;
const requestedPixelRatio = window.devicePixelRatio * resolutionMultiplier;
const { width, height } = this._getViewportSize();
const viewportPixelCount = width * height;
const maxPixelRatioForBudget = Math.sqrt(MAX_RENDER_TARGET_PIXELS / viewportPixelCount);

return Math.max(
MIN_RENDER_PIXEL_RATIO,
Math.min(requestedPixelRatio, maxPixelRatioForBudget),
);
}

private _applyRenderResolution(): void {
const { width, height } = this._getViewportSize();
this._renderer.setPixelRatio(this._calculateEffectivePixelRatio());
this._renderer.setSize(width, height);
this._sceneUiRenderer.setSize(width, height);
this._resizePostProcessing();
}

private _setupPostProcessing(): void {
this._effectComposer.addPass(this._renderPass);
this._effectComposer.addPass(this._outlinePass);
Expand Down Expand Up @@ -296,18 +329,21 @@ export default class Renderer {
this._renderer.info.reset();
const pp = this._game.settingsManager.qualityPerfTradeoff.postProcessing;
if (pp?.outline || pp?.bloom || pp?.smaa) {
const hasOutlineTargets = !!pp.outline && this._game.entityManager.hasOutlines;
this._renderPass.camera = this._game.camera.activeCamera;
this._viewModelRenderPass.camera = this._game.camera.activeCamera;
this._viewModelRenderPass.enabled = this._firstPersonViewModelEntity !== undefined;
this._outlinePass.enabled = !!pp.outline;
this._bloomPass.enabled = !!pp.bloom;
this._smaaPass.enabled = !!pp.smaa;
if (pp.outline) {
if (hasOutlineTargets) {
this._outlinePass.camera = this._game.camera.activeCamera;
this._outlinePass.setOutlineTargets(this._game.entityManager.getOutlineTargets());
} else {
this._outlinePass.clearOutlineTargets();
}
this._effectComposer.render();
if (pp.outline) {
if (hasOutlineTargets) {
this._game.entityManager.clearOutlineTargets();
this._outlinePass.clearOutlineTargets();
}
Expand Down Expand Up @@ -400,9 +436,7 @@ export default class Renderer {
// document.documentElement.clientHeight are used instead. However, it needs to be
// verified whether this solution works correctly on other platforms as well.
this._game.camera.onWindowResize();
this._renderer.setSize(document.documentElement.clientWidth, document.documentElement.clientHeight);
this._sceneUiRenderer.setSize(document.documentElement.clientWidth, document.documentElement.clientHeight);
this._resizePostProcessing();
this._applyRenderResolution();
}

private _onWorldPacket = (payload: NetworkManagerEventPayload.IWorldPacket): void => {
Expand Down Expand Up @@ -490,13 +524,9 @@ export default class Renderer {
}

private _onClientSettingsUpdate = (_payload: ClientSettingsEventPayload.IUpdate): void => {
const { resolution } = this._game.settingsManager.qualityPerfTradeoff;

this._renderer.setPixelRatio(window.devicePixelRatio * resolution.multiplier);

this._applyRenderResolution();
this._clampTargetFogNearAndFar();
this._setupFog();
this._resizePostProcessing();
};

private _setupEventListeners(): void {
Expand Down Expand Up @@ -589,8 +619,7 @@ export default class Renderer {
}

private _setupRenderer(): void {
this._renderer.setSize(document.documentElement.clientWidth, document.documentElement.clientHeight);
this._renderer.setPixelRatio(window.devicePixelRatio * this._game.settingsManager.qualityPerfTradeoff.resolution.multiplier);
this._applyRenderResolution();
this._renderer.info.autoReset = false;
this._renderer.localClippingEnabled = false;
// Be explicit about output space; this is cheap and avoids surprises across Three.js versions.
Expand Down
7 changes: 4 additions & 3 deletions client/src/entities/Entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ import {
Quaternion,
type QuaternionLike,
Texture,
Vector2,
Vector3,
type Vector3Like,
WebGLProgramParametersWithUniforms,
} from 'three';
import type { Vector2 } from 'three';
import type { GLTF } from 'three/addons/loaders/GLTFLoader.js'
import { type EntityId } from './EntityConstants';
import EntityStats from './EntityStats';
Expand Down Expand Up @@ -53,7 +53,6 @@ const MAX_UPDATE_SKIP_FRAMES = 4;
const NEAR_DISTANCE_SQUARED = 16 * 16; // 1 Chunk = 16 Blocks

// Working variables
const vec2 = new Vector2();
const corners: Vector3[] = new Array(8).fill(undefined).map(() => new Vector3());
const color = new Color();
const quaternion = new Quaternion();
Expand Down Expand Up @@ -2162,7 +2161,9 @@ export default class Entity {
// sync their visibility with the chunk. Otherwise, there could be cases where a chunk is invisible
// but the entity remains visible, which could make the entity appear to be floating in mid-air.
const coord = this.position;
this._distanceToCameraSquared = fromVec2.distanceToSquared(vec2.set(coord.x, coord.z));
const dx = fromVec2.x - coord.x;
const dz = fromVec2.y - coord.z;
this._distanceToCameraSquared = dx * dx + dz * dz;
this.visible = this._distanceToCameraSquared <= viewDistanceSquared;
if (this.visible) {
EntityStats.inViewDistanceCount++;
Expand Down
Loading