feat(world-streaming): 添加世界流式加载系统 (#288)

实现基于区块的世界流式加载系统,支持开放世界游戏:

运行时包 (@esengine/world-streaming):
- ChunkComponent: 区块实体组件,包含坐标、边界、状态
- StreamingAnchorComponent: 流式锚点组件(玩家/摄像机)
- ChunkLoaderComponent: 流式加载配置组件
- ChunkStreamingSystem: 区块加载/卸载调度系统
- ChunkCullingSystem: 区块可见性剔除系统
- ChunkManager: 区块生命周期管理服务
- SpatialHashGrid: 空间哈希网格
- ChunkSerializer: 区块序列化

编辑器包 (@esengine/world-streaming-editor):
- ChunkVisualizer: 区块可视化覆盖层
- ChunkLoaderInspectorProvider: 区块加载器检视器
- StreamingAnchorInspectorProvider: 流式锚点检视器
- WorldStreamingPlugin: 完整插件导出
This commit is contained in:
YHH
2025-12-06 13:56:01 +08:00
committed by GitHub
parent 3cbfa1e4cb
commit 0c03b13d74
31 changed files with 2802 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
import { ComponentRegistry } from '@esengine/ecs-framework';
import type { IRuntimeModule, SystemContext } from '@esengine/engine-core';
import { ChunkComponent } from './components/ChunkComponent';
import { StreamingAnchorComponent } from './components/StreamingAnchorComponent';
import { ChunkLoaderComponent } from './components/ChunkLoaderComponent';
import { ChunkStreamingSystem } from './systems/ChunkStreamingSystem';
import { ChunkCullingSystem } from './systems/ChunkCullingSystem';
import { ChunkManager } from './services/ChunkManager';
/**
* 世界流式加载模块
*
* Runtime module for world streaming functionality.
*
* 提供世界流式加载功能的运行时模块。
*/
export class WorldStreamingModule implements IRuntimeModule {
private _chunkManager: ChunkManager | null = null;
get chunkManager(): ChunkManager | null {
return this._chunkManager;
}
registerComponents(registry: typeof ComponentRegistry): void {
registry.register(ChunkComponent);
registry.register(StreamingAnchorComponent);
registry.register(ChunkLoaderComponent);
}
registerServices(services: ServiceContainer): void {
this._chunkManager = new ChunkManager();
services.registerInstance(ChunkManager, this._chunkManager);
}
createSystems(scene: IScene, _context: SystemContext): void {
const streamingSystem = new ChunkStreamingSystem();
if (this._chunkManager) {
streamingSystem.setChunkManager(this._chunkManager);
}
scene.addSystem(streamingSystem);
scene.addSystem(new ChunkCullingSystem());
}
onSystemsCreated(_scene: IScene, _context: SystemContext): void {
// No post-creation setup needed
}
}
export const worldStreamingModule = new WorldStreamingModule();

View File

@@ -0,0 +1,103 @@
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
import type { IChunkCoord, IChunkBounds } from '../types';
import { EChunkState } from '../types';
/**
* 区块组件
*
* Attached to chunk root entities. Tracks chunk state and bounds.
*
* 区块组件挂载在区块根实体上,用于管理区块状态和边界信息。
*/
@ECSComponent('Chunk')
@Serializable({ version: 1, typeId: 'Chunk' })
export class ChunkComponent extends Component {
@Serialize()
@Property({ type: 'integer', label: 'Coord X', readOnly: true })
private _coordX: number = 0;
@Serialize()
@Property({ type: 'integer', label: 'Coord Y', readOnly: true })
private _coordY: number = 0;
@Serialize()
@Property({ type: 'string', label: 'State', readOnly: true })
private _state: EChunkState = EChunkState.Unloaded;
@Serialize()
private _minX: number = 0;
@Serialize()
private _minY: number = 0;
@Serialize()
private _maxX: number = 0;
@Serialize()
private _maxY: number = 0;
private _lastAccessTime: number = 0;
get coord(): IChunkCoord {
return { x: this._coordX, y: this._coordY };
}
get bounds(): IChunkBounds {
return {
minX: this._minX,
minY: this._minY,
maxX: this._maxX,
maxY: this._maxY
};
}
get state(): EChunkState {
return this._state;
}
get lastAccessTime(): number {
return this._lastAccessTime;
}
/**
* 初始化区块
*
* Initialize chunk with coordinates and bounds.
*/
initialize(coord: IChunkCoord, bounds: IChunkBounds): void {
this._coordX = coord.x;
this._coordY = coord.y;
this._minX = bounds.minX;
this._minY = bounds.minY;
this._maxX = bounds.maxX;
this._maxY = bounds.maxY;
this._lastAccessTime = Date.now();
}
/**
* 设置区块状态
*
* Set chunk state.
*/
setState(state: EChunkState): void {
this._state = state;
}
/**
* 更新访问时间
*
* Update last access time for LRU tracking.
*/
touch(): void {
this._lastAccessTime = Date.now();
}
/**
* 检查点是否在区块内
*
* Check if a point is within chunk bounds.
*/
containsPoint(x: number, y: number): boolean {
return x >= this._minX && x < this._maxX && y >= this._minY && y < this._maxY;
}
}

View File

@@ -0,0 +1,133 @@
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
import type { IChunkCoord, IStreamingConfig } from '../types';
import { DEFAULT_STREAMING_CONFIG } from '../types';
/**
* 区块加载器组件
*
* Singleton component that manages streaming configuration.
* Attach to a manager entity in the scene.
*
* 单例组件,管理流式加载配置。挂载在场景管理实体上。
*/
@ECSComponent('ChunkLoader')
@Serializable({ version: 1, typeId: 'ChunkLoader' })
export class ChunkLoaderComponent extends Component {
@Serialize()
@Property({ type: 'integer', label: 'Chunk Size', min: 64, max: 4096 })
private _chunkSize: number = DEFAULT_STREAMING_CONFIG.chunkSize;
@Serialize()
@Property({ type: 'integer', label: 'Load Radius', min: 1, max: 10 })
private _loadRadius: number = DEFAULT_STREAMING_CONFIG.loadRadius;
@Serialize()
@Property({ type: 'integer', label: 'Unload Radius', min: 2, max: 20 })
private _unloadRadius: number = DEFAULT_STREAMING_CONFIG.unloadRadius;
@Serialize()
@Property({ type: 'integer', label: 'Max Loads Per Frame', min: 1, max: 10 })
private _maxLoadsPerFrame: number = DEFAULT_STREAMING_CONFIG.maxLoadsPerFrame;
@Serialize()
@Property({ type: 'integer', label: 'Max Unloads Per Frame', min: 1, max: 10 })
private _maxUnloadsPerFrame: number = DEFAULT_STREAMING_CONFIG.maxUnloadsPerFrame;
@Serialize()
@Property({ type: 'integer', label: 'Unload Delay (ms)', min: 0, max: 30000 })
private _unloadDelay: number = DEFAULT_STREAMING_CONFIG.unloadDelay;
@Serialize()
@Property({ type: 'boolean', label: 'Enable Prefetch' })
private _bEnablePrefetch: boolean = DEFAULT_STREAMING_CONFIG.bEnablePrefetch;
@Serialize()
@Property({ type: 'integer', label: 'Prefetch Radius', min: 0, max: 5 })
private _prefetchRadius: number = DEFAULT_STREAMING_CONFIG.prefetchRadius;
get chunkSize(): number {
return this._chunkSize;
}
get loadRadius(): number {
return this._loadRadius;
}
get unloadRadius(): number {
return this._unloadRadius;
}
get maxLoadsPerFrame(): number {
return this._maxLoadsPerFrame;
}
get maxUnloadsPerFrame(): number {
return this._maxUnloadsPerFrame;
}
get unloadDelay(): number {
return this._unloadDelay;
}
get bEnablePrefetch(): boolean {
return this._bEnablePrefetch;
}
get prefetchRadius(): number {
return this._prefetchRadius;
}
/**
* 应用配置
*
* Apply streaming configuration.
*/
applyConfig(config: Partial<IStreamingConfig>): void {
if (config.chunkSize !== undefined) this._chunkSize = config.chunkSize;
if (config.loadRadius !== undefined) this._loadRadius = config.loadRadius;
if (config.unloadRadius !== undefined) this._unloadRadius = config.unloadRadius;
if (config.maxLoadsPerFrame !== undefined) this._maxLoadsPerFrame = config.maxLoadsPerFrame;
if (config.maxUnloadsPerFrame !== undefined) this._maxUnloadsPerFrame = config.maxUnloadsPerFrame;
if (config.unloadDelay !== undefined) this._unloadDelay = config.unloadDelay;
if (config.bEnablePrefetch !== undefined) this._bEnablePrefetch = config.bEnablePrefetch;
if (config.prefetchRadius !== undefined) this._prefetchRadius = config.prefetchRadius;
}
/**
* 世界坐标转区块坐标
*
* Convert world position to chunk coordinates.
*/
worldToChunk(worldX: number, worldY: number): IChunkCoord {
return {
x: Math.floor(worldX / this._chunkSize),
y: Math.floor(worldY / this._chunkSize)
};
}
/**
* 区块坐标转世界坐标(返回区块中心)
*
* Convert chunk coordinates to world position (chunk center).
*/
chunkToWorld(coord: IChunkCoord): { x: number; y: number } {
return {
x: coord.x * this._chunkSize + this._chunkSize * 0.5,
y: coord.y * this._chunkSize + this._chunkSize * 0.5
};
}
/**
* 获取区块边界
*
* Get chunk world-space bounds.
*/
getChunkBounds(coord: IChunkCoord): { minX: number; minY: number; maxX: number; maxY: number } {
return {
minX: coord.x * this._chunkSize,
minY: coord.y * this._chunkSize,
maxX: (coord.x + 1) * this._chunkSize,
maxY: (coord.y + 1) * this._chunkSize
};
}
}

View File

@@ -0,0 +1,61 @@
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
/**
* 流式锚点组件
*
* Marks an entity as a streaming anchor point.
* Chunks are loaded/unloaded based on distance to anchors.
*
* 标记实体作为流式加载锚点。通常挂载在玩家或摄像机实体上,
* 系统会根据锚点位置加载/卸载周围区块。
*/
@ECSComponent('StreamingAnchor')
@Serializable({ version: 1, typeId: 'StreamingAnchor' })
export class StreamingAnchorComponent extends Component {
/**
* 锚点权重
*
* Weight multiplier for this anchor's load radius.
* Higher values mean larger load radius around this anchor.
*/
@Serialize()
@Property({ type: 'number', label: 'Weight', min: 0.1, max: 10 })
weight: number = 1.0;
/**
* 是否启用预加载
*
* Enable directional prefetching based on movement.
*/
@Serialize()
@Property({ type: 'boolean', label: 'Enable Prefetch' })
bEnablePrefetch: boolean = true;
/**
* 上一帧位置 X
*
* Previous frame X position for velocity calculation.
*/
previousX: number = 0;
/**
* 上一帧位置 Y
*
* Previous frame Y position for velocity calculation.
*/
previousY: number = 0;
/**
* 速度 X 分量
*
* X component of velocity (units per second).
*/
velocityX: number = 0;
/**
* 速度 Y 分量
*
* Y component of velocity (units per second).
*/
velocityY: number = 0;
}

View File

@@ -0,0 +1,3 @@
export { ChunkComponent } from './ChunkComponent';
export { StreamingAnchorComponent } from './StreamingAnchorComponent';
export { ChunkLoaderComponent } from './ChunkLoaderComponent';

View File

@@ -0,0 +1,53 @@
/**
* @esengine/world-streaming
*
* World streaming and chunk management system for open world games.
*
* 世界流式加载和区块管理系统,用于开放世界游戏。
*/
// Types
export {
EChunkState,
EChunkPriority,
DEFAULT_STREAMING_CONFIG
} from './types';
export type {
IChunkCoord,
IChunkBounds,
IChunkData,
ISerializedEntity,
IChunkInfo,
IChunkLoadRequest,
IStreamingConfig
} from './types';
// Components
export {
ChunkComponent,
StreamingAnchorComponent,
ChunkLoaderComponent
} from './components';
// Systems
export {
ChunkStreamingSystem,
ChunkCullingSystem
} from './systems';
// Services
export {
SpatialHashGrid,
ChunkSerializer,
ChunkManager
} from './services';
export type {
IChunkSerializer,
IChunkDataProvider,
IChunkManagerEvents
} from './services';
// Module
export { WorldStreamingModule, worldStreamingModule } from './WorldStreamingModule';

View File

@@ -0,0 +1,421 @@
import type { Entity, IScene, IService } from '@esengine/ecs-framework';
import { TransformComponent } from '@esengine/engine-core';
import type { IChunkCoord, IChunkData, IChunkInfo, IChunkLoadRequest, IChunkBounds } from '../types';
import { EChunkState, EChunkPriority } from '../types';
import { SpatialHashGrid } from './SpatialHashGrid';
import type { IChunkSerializer } from './ChunkSerializer';
import { ChunkSerializer } from './ChunkSerializer';
import { ChunkComponent } from '../components/ChunkComponent';
/**
* 区块数据提供者接口
*
* Interface for chunk data loading/saving.
*/
export interface IChunkDataProvider {
loadChunkData(coord: IChunkCoord): Promise<IChunkData | null>;
saveChunkData(data: IChunkData): Promise<void>;
}
/**
* 区块管理器事件
*
* Events emitted by ChunkManager.
*/
export interface IChunkManagerEvents {
onChunkLoaded?: (coord: IChunkCoord, entities: Entity[]) => void;
onChunkUnloaded?: (coord: IChunkCoord) => void;
onChunkLoadFailed?: (coord: IChunkCoord, error: Error) => void;
}
/**
* 区块管理器
*
* Manages chunk lifecycle, loading queue, and spatial queries.
*
* 区块管理器负责区块生命周期、加载队列和空间查询。
*/
export class ChunkManager implements IService {
private _chunkGrid: SpatialHashGrid<IChunkInfo>;
private _loadQueue: IChunkLoadRequest[] = [];
private _unloadQueue: Array<{ coord: IChunkCoord; scheduledTime: number }> = [];
private _scene: IScene | null = null;
private _dataProvider: IChunkDataProvider | null = null;
private _serializer: IChunkSerializer;
private _events: IChunkManagerEvents = {};
private _chunkSize: number;
constructor(chunkSize: number = 512, serializer?: IChunkSerializer) {
this._chunkSize = chunkSize;
this._chunkGrid = new SpatialHashGrid<IChunkInfo>(chunkSize);
this._serializer = serializer ?? new ChunkSerializer();
}
get chunkSize(): number {
return this._chunkSize;
}
get loadedChunkCount(): number {
return this._chunkGrid.size;
}
get pendingLoadCount(): number {
return this._loadQueue.length;
}
get pendingUnloadCount(): number {
return this._unloadQueue.length;
}
/**
* 设置场景
*
* Set the scene for entity creation.
*/
setScene(scene: IScene): void {
this._scene = scene;
}
/**
* 设置数据提供者
*
* Set the chunk data provider.
*/
setDataProvider(provider: IChunkDataProvider): void {
this._dataProvider = provider;
}
/**
* 设置事件回调
*
* Set event callbacks.
*/
setEvents(events: IChunkManagerEvents): void {
this._events = events;
}
/**
* 请求加载区块
*
* Request a chunk to be loaded.
*/
requestLoad(coord: IChunkCoord, priority: EChunkPriority = EChunkPriority.Normal): void {
const existing = this._chunkGrid.get(coord);
if (existing && existing.state !== EChunkState.Unloaded && existing.state !== EChunkState.Failed) {
if (existing.state === EChunkState.Loaded) {
existing.lastAccessTime = Date.now();
}
return;
}
const existingRequest = this._loadQueue.find(
(r) => r.coord.x === coord.x && r.coord.y === coord.y
);
if (existingRequest) {
if (priority < existingRequest.priority) {
existingRequest.priority = priority;
}
return;
}
this._loadQueue.push({
coord,
priority,
timestamp: Date.now()
});
this.sortLoadQueue();
}
/**
* 请求卸载区块
*
* Request a chunk to be unloaded.
*/
requestUnload(coord: IChunkCoord, delay: number = 0): void {
const chunk = this._chunkGrid.get(coord);
if (!chunk || chunk.state !== EChunkState.Loaded) {
return;
}
const existingRequest = this._unloadQueue.find(
(r) => r.coord.x === coord.x && r.coord.y === coord.y
);
if (!existingRequest) {
this._unloadQueue.push({
coord,
scheduledTime: Date.now() + delay
});
}
}
/**
* 取消卸载请求
*
* Cancel a pending unload request.
*/
cancelUnload(coord: IChunkCoord): void {
const index = this._unloadQueue.findIndex(
(r) => r.coord.x === coord.x && r.coord.y === coord.y
);
if (index >= 0) {
this._unloadQueue.splice(index, 1);
}
}
/**
* 处理加载队列
*
* Process pending load requests.
*/
async processLoads(maxCount: number): Promise<void> {
let processed = 0;
while (processed < maxCount && this._loadQueue.length > 0) {
const request = this._loadQueue.shift()!;
await this.loadChunk(request.coord);
processed++;
}
}
/**
* 处理卸载队列
*
* Process pending unload requests.
*/
processUnloads(maxCount: number): void {
const now = Date.now();
let processed = 0;
const readyToUnload = this._unloadQueue.filter((r) => r.scheduledTime <= now);
for (const request of readyToUnload) {
if (processed >= maxCount) break;
this.unloadChunk(request.coord);
processed++;
const index = this._unloadQueue.indexOf(request);
if (index >= 0) {
this._unloadQueue.splice(index, 1);
}
}
}
/**
* 加载区块
*
* Load a single chunk.
*/
private async loadChunk(coord: IChunkCoord): Promise<void> {
if (!this._scene) {
console.warn('[ChunkManager] No scene set');
return;
}
const bounds = this.getChunkBounds(coord);
const chunkInfo: IChunkInfo = {
coord,
state: EChunkState.Loading,
priority: EChunkPriority.Normal,
entities: [],
bounds,
lastAccessTime: Date.now(),
distanceSq: 0
};
this._chunkGrid.set(coord, chunkInfo);
try {
let entities: Entity[];
if (this._dataProvider) {
const data = await this._dataProvider.loadChunkData(coord);
if (data) {
entities = this._serializer.deserialize(data, this._scene);
} else {
entities = this.createEmptyChunk(coord, bounds);
}
} else {
entities = this.createEmptyChunk(coord, bounds);
}
chunkInfo.entities = entities;
chunkInfo.state = EChunkState.Loaded;
chunkInfo.lastAccessTime = Date.now();
this._events.onChunkLoaded?.(coord, entities);
} catch (error) {
chunkInfo.state = EChunkState.Failed;
this._events.onChunkLoadFailed?.(coord, error as Error);
}
}
/**
* 卸载区块
*
* Unload a single chunk.
*/
private unloadChunk(coord: IChunkCoord): void {
const chunk = this._chunkGrid.get(coord);
if (!chunk) return;
chunk.state = EChunkState.Unloading;
for (const entity of chunk.entities) {
entity.destroy();
}
this._chunkGrid.delete(coord);
this._events.onChunkUnloaded?.(coord);
}
/**
* 创建空区块
*
* Create an empty chunk entity.
*/
private createEmptyChunk(coord: IChunkCoord, bounds: IChunkBounds): Entity[] {
if (!this._scene) return [];
const chunkEntity = this._scene.createEntity(`Chunk_${coord.x}_${coord.y}`);
const chunkComponent = chunkEntity.addComponent(new ChunkComponent());
chunkComponent.initialize(coord, bounds);
chunkComponent.setState(EChunkState.Loaded);
const transform = chunkEntity.getComponent(TransformComponent);
if (transform) {
transform.setPosition(bounds.minX, bounds.minY);
}
return [chunkEntity];
}
/**
* 获取区块边界
*
* Get world-space bounds for a chunk.
*/
getChunkBounds(coord: IChunkCoord): IChunkBounds {
return {
minX: coord.x * this._chunkSize,
minY: coord.y * this._chunkSize,
maxX: (coord.x + 1) * this._chunkSize,
maxY: (coord.y + 1) * this._chunkSize
};
}
/**
* 世界坐标转区块坐标
*
* Convert world position to chunk coordinates.
*/
worldToChunk(worldX: number, worldY: number): IChunkCoord {
return {
x: Math.floor(worldX / this._chunkSize),
y: Math.floor(worldY / this._chunkSize)
};
}
/**
* 获取区块信息
*
* Get chunk info by coordinates.
*/
getChunk(coord: IChunkCoord): IChunkInfo | undefined {
return this._chunkGrid.get(coord);
}
/**
* 检查区块是否已加载
*
* Check if a chunk is loaded.
*/
isChunkLoaded(coord: IChunkCoord): boolean {
const chunk = this._chunkGrid.get(coord);
return chunk !== undefined && chunk.state === EChunkState.Loaded;
}
/**
* 获取需要加载的区块坐标
*
* Get chunk coordinates that need to be loaded within radius.
*/
getMissingChunks(centerCoord: IChunkCoord, radius: number): IChunkCoord[] {
const missing: IChunkCoord[] = [];
for (let dx = -radius; dx <= radius; dx++) {
for (let dy = -radius; dy <= radius; dy++) {
const coord = { x: centerCoord.x + dx, y: centerCoord.y + dy };
if (!this.isChunkLoaded(coord)) {
missing.push(coord);
}
}
}
return missing;
}
/**
* 获取超出范围的已加载区块
*
* Get loaded chunks outside the given radius.
*/
getChunksOutsideRadius(centerCoord: IChunkCoord, radius: number): IChunkCoord[] {
const outside: IChunkCoord[] = [];
this._chunkGrid.forEach((_info, coord) => {
const dx = Math.abs(coord.x - centerCoord.x);
const dy = Math.abs(coord.y - centerCoord.y);
if (dx > radius || dy > radius) {
outside.push(coord);
}
});
return outside;
}
/**
* 排序加载队列
*
* Sort load queue by priority and timestamp.
*/
private sortLoadQueue(): void {
this._loadQueue.sort((a, b) => {
if (a.priority !== b.priority) {
return a.priority - b.priority;
}
return a.timestamp - b.timestamp;
});
}
/**
* 清空所有区块
*
* Unload all chunks.
*/
clear(): void {
this._chunkGrid.forEach((_info, coord) => {
this.unloadChunk(coord);
});
this._loadQueue = [];
this._unloadQueue = [];
}
/**
* 释放资源
*
* Dispose resources (IService interface).
*/
dispose(): void {
this.clear();
this._scene = null;
this._dataProvider = null;
this._events = {};
}
}

View File

@@ -0,0 +1,145 @@
import type { Entity, IScene } from '@esengine/ecs-framework';
import { TransformComponent } from '@esengine/engine-core';
import type { IChunkCoord, IChunkData, ISerializedEntity, IChunkBounds } from '../types';
/**
* 区块序列化器接口
*
* Interface for chunk serialization/deserialization.
*/
export interface IChunkSerializer {
serialize(coord: IChunkCoord, entities: Entity[], bounds: IChunkBounds): IChunkData;
deserialize(data: IChunkData, scene: IScene): Entity[];
}
/**
* 默认区块序列化器
*
* Default chunk serializer implementation.
* Override for custom serialization logic.
*/
export class ChunkSerializer implements IChunkSerializer {
private static readonly DATA_VERSION = 1;
/**
* 序列化区块
*
* Serialize entities within a chunk.
*/
serialize(coord: IChunkCoord, entities: Entity[], bounds: IChunkBounds): IChunkData {
const serializedEntities: ISerializedEntity[] = [];
for (const entity of entities) {
const transform = entity.getComponent(TransformComponent);
if (!transform) continue;
const serialized: ISerializedEntity = {
name: entity.name,
localPosition: {
x: transform.position.x - bounds.minX,
y: transform.position.y - bounds.minY
},
components: this.serializeComponents(entity)
};
serializedEntities.push(serialized);
}
return {
coord,
entities: serializedEntities,
version: ChunkSerializer.DATA_VERSION
};
}
/**
* 反序列化区块
*
* Deserialize chunk data and create entities.
*/
deserialize(data: IChunkData, scene: IScene): Entity[] {
const entities: Entity[] = [];
const bounds = this.calculateBounds(data.coord);
for (const entityData of data.entities) {
const entity = scene.createEntity(entityData.name);
const transform = entity.getComponent(TransformComponent);
if (transform) {
transform.setPosition(
bounds.minX + entityData.localPosition.x,
bounds.minY + entityData.localPosition.y
);
}
this.deserializeComponents(entity, entityData.components);
entities.push(entity);
}
return entities;
}
/**
* 序列化实体组件
*
* Serialize entity components.
*/
protected serializeComponents(entity: Entity): Record<string, unknown> {
const componentsData: Record<string, unknown> = {};
for (const component of entity.components) {
const componentName = component.constructor.name;
if (this.shouldSerializeComponent(componentName)) {
componentsData[componentName] = this.serializeComponent(component);
}
}
return componentsData;
}
/**
* 反序列化组件数据
*
* Deserialize component data to entity.
*/
protected deserializeComponents(_entity: Entity, _components: Record<string, unknown>): void {
// Override in subclass to handle specific component types
}
/**
* 检查组件是否需要序列化
*
* Check if component should be serialized.
*/
protected shouldSerializeComponent(componentName: string): boolean {
const excludeList = ['TransformComponent', 'ChunkComponent', 'StreamingAnchorComponent'];
return !excludeList.includes(componentName);
}
/**
* 序列化单个组件
*
* Serialize a single component.
*/
protected serializeComponent(component: unknown): unknown {
if (typeof component === 'object' && component !== null && 'toJSON' in component) {
return (component as { toJSON: () => unknown }).toJSON();
}
return {};
}
/**
* 计算区块边界
*
* Calculate chunk bounds from coordinates.
*/
private calculateBounds(coord: IChunkCoord, chunkSize: number = 512): IChunkBounds {
return {
minX: coord.x * chunkSize,
minY: coord.y * chunkSize,
maxX: (coord.x + 1) * chunkSize,
maxY: (coord.y + 1) * chunkSize
};
}
}

View File

@@ -0,0 +1,173 @@
import type { IChunkCoord } from '../types';
/**
* 空间哈希网格
*
* Spatial hash grid for fast chunk lookups.
*
* 用于快速查询指定位置或范围内的区块。
*/
export class SpatialHashGrid<T> {
private _cells: Map<string, T> = new Map();
private _cellSize: number;
constructor(cellSize: number) {
this._cellSize = cellSize;
}
get cellSize(): number {
return this._cellSize;
}
get size(): number {
return this._cells.size;
}
/**
* 生成网格键
*
* Generate hash key from coordinates.
*/
private getKey(x: number, y: number): string {
return `${x},${y}`;
}
/**
* 设置单元格数据
*
* Set data at grid coordinates.
*/
set(coord: IChunkCoord, value: T): void {
this._cells.set(this.getKey(coord.x, coord.y), value);
}
/**
* 获取单元格数据
*
* Get data at grid coordinates.
*/
get(coord: IChunkCoord): T | undefined {
return this._cells.get(this.getKey(coord.x, coord.y));
}
/**
* 检查单元格是否存在
*
* Check if data exists at coordinates.
*/
has(coord: IChunkCoord): boolean {
return this._cells.has(this.getKey(coord.x, coord.y));
}
/**
* 删除单元格
*
* Delete data at coordinates.
*/
delete(coord: IChunkCoord): boolean {
return this._cells.delete(this.getKey(coord.x, coord.y));
}
/**
* 清空网格
*
* Clear all cells.
*/
clear(): void {
this._cells.clear();
}
/**
* 世界坐标转网格坐标
*
* Convert world position to grid coordinates.
*/
worldToGrid(worldX: number, worldY: number): IChunkCoord {
return {
x: Math.floor(worldX / this._cellSize),
y: Math.floor(worldY / this._cellSize)
};
}
/**
* 查询范围内的所有单元格
*
* Query all cells within a range.
*/
queryRange(centerCoord: IChunkCoord, radius: number): T[] {
const results: T[] = [];
for (let dx = -radius; dx <= radius; dx++) {
for (let dy = -radius; dy <= radius; dy++) {
const value = this.get({ x: centerCoord.x + dx, y: centerCoord.y + dy });
if (value !== undefined) {
results.push(value);
}
}
}
return results;
}
/**
* 获取范围内需要加载的坐标
*
* Get coordinates within range that need loading.
*/
getMissingInRange(centerCoord: IChunkCoord, radius: number): IChunkCoord[] {
const missing: IChunkCoord[] = [];
for (let dx = -radius; dx <= radius; dx++) {
for (let dy = -radius; dy <= radius; dy++) {
const coord = { x: centerCoord.x + dx, y: centerCoord.y + dy };
if (!this.has(coord)) {
missing.push(coord);
}
}
}
return missing;
}
/**
* 获取范围外的所有单元格
*
* Get all cells outside a given range.
*/
getOutsideRange(centerCoord: IChunkCoord, radius: number): Array<{ coord: IChunkCoord; value: T }> {
const outside: Array<{ coord: IChunkCoord; value: T }> = [];
for (const [key, value] of this._cells) {
const [x, y] = key.split(',').map(Number);
const dx = Math.abs(x - centerCoord.x);
const dy = Math.abs(y - centerCoord.y);
if (dx > radius || dy > radius) {
outside.push({ coord: { x, y }, value });
}
}
return outside;
}
/**
* 遍历所有单元格
*
* Iterate over all cells.
*/
forEach(callback: (value: T, coord: IChunkCoord) => void): void {
for (const [key, value] of this._cells) {
const [x, y] = key.split(',').map(Number);
callback(value, { x, y });
}
}
/**
* 获取所有值
*
* Get all values.
*/
values(): IterableIterator<T> {
return this._cells.values();
}
}

View File

@@ -0,0 +1,5 @@
export { SpatialHashGrid } from './SpatialHashGrid';
export { ChunkSerializer } from './ChunkSerializer';
export type { IChunkSerializer } from './ChunkSerializer';
export { ChunkManager } from './ChunkManager';
export type { IChunkDataProvider, IChunkManagerEvents } from './ChunkManager';

View File

@@ -0,0 +1,108 @@
import { EntitySystem, Matcher, ECSSystem } from '@esengine/ecs-framework';
import type { Entity } from '@esengine/ecs-framework';
import { ChunkComponent } from '../components/ChunkComponent';
import { EChunkState } from '../types';
/**
* 区块裁剪系统
*
* Handles visibility culling for chunk entities.
*
* 处理区块实体的可见性裁剪。
*/
@ECSSystem('ChunkCulling', { updateOrder: -40 })
export class ChunkCullingSystem extends EntitySystem {
private _viewMinX: number = 0;
private _viewMinY: number = 0;
private _viewMaxX: number = 1920;
private _viewMaxY: number = 1080;
private _padding: number = 100;
constructor() {
super(Matcher.all(ChunkComponent));
}
/**
* 设置视口边界
*
* Set viewport bounds for culling.
*/
setViewBounds(minX: number, minY: number, maxX: number, maxY: number): void {
this._viewMinX = minX;
this._viewMinY = minY;
this._viewMaxX = maxX;
this._viewMaxY = maxY;
}
/**
* 设置裁剪边距
*
* Set padding for culling bounds.
*/
setPadding(padding: number): void {
this._padding = padding;
}
protected process(entities: readonly Entity[]): void {
const cullMinX = this._viewMinX - this._padding;
const cullMinY = this._viewMinY - this._padding;
const cullMaxX = this._viewMaxX + this._padding;
const cullMaxY = this._viewMaxY + this._padding;
for (const entity of entities) {
const chunk = entity.getComponent(ChunkComponent);
if (!chunk) continue;
if (chunk.state !== EChunkState.Loaded) continue;
const bounds = chunk.bounds;
const isVisible = this.boundsIntersect(
bounds.minX,
bounds.minY,
bounds.maxX,
bounds.maxY,
cullMinX,
cullMinY,
cullMaxX,
cullMaxY
);
entity.enabled = isVisible;
}
}
/**
* 检查边界是否相交
*
* Check if two axis-aligned bounds intersect.
*/
private boundsIntersect(
aMinX: number,
aMinY: number,
aMaxX: number,
aMaxY: number,
bMinX: number,
bMinY: number,
bMaxX: number,
bMaxY: number
): boolean {
return aMinX < bMaxX && aMaxX > bMinX && aMinY < bMaxY && aMaxY > bMinY;
}
/**
* 更新视口(从相机)
*
* Update viewport from camera position and size.
*/
updateFromCamera(cameraX: number, cameraY: number, viewWidth: number, viewHeight: number): void {
const halfWidth = viewWidth * 0.5;
const halfHeight = viewHeight * 0.5;
this.setViewBounds(
cameraX - halfWidth,
cameraY - halfHeight,
cameraX + halfWidth,
cameraY + halfHeight
);
}
}

View File

@@ -0,0 +1,273 @@
import { EntitySystem, Matcher, Time, ECSSystem } from '@esengine/ecs-framework';
import type { Entity, Scene } from '@esengine/ecs-framework';
import { TransformComponent } from '@esengine/engine-core';
import { StreamingAnchorComponent } from '../components/StreamingAnchorComponent';
import { ChunkLoaderComponent } from '../components/ChunkLoaderComponent';
import { ChunkManager } from '../services/ChunkManager';
import { EChunkPriority } from '../types';
import type { IChunkCoord } from '../types';
/**
* 区块流式加载系统
*
* Manages chunk loading/unloading based on streaming anchors.
*
* 根据流式锚点位置管理区块的加载和卸载。
*/
@ECSSystem('ChunkStreaming', { updateOrder: -50 })
export class ChunkStreamingSystem extends EntitySystem {
private _chunkManager: ChunkManager | null = null;
private _loaderEntity: Entity | null = null;
private _lastAnchorChunks: Map<Entity, IChunkCoord> = new Map();
constructor() {
super(Matcher.all(StreamingAnchorComponent, TransformComponent));
}
/**
* 设置区块管理器
*
* Set the chunk manager instance.
*/
setChunkManager(manager: ChunkManager): void {
this._chunkManager = manager;
}
/**
* 获取区块管理器
*
* Get the chunk manager instance.
*/
get chunkManager(): ChunkManager | null {
return this._chunkManager;
}
initialize(): void {
super.initialize();
if (!this._chunkManager) {
this._chunkManager = new ChunkManager();
}
const scene = this.scene;
if (scene) {
this._chunkManager.setScene(scene);
this.findLoaderEntity(scene);
}
}
protected process(entities: readonly Entity[]): void {
if (!this._chunkManager) {
return;
}
const loader = this.getLoaderComponent();
if (!loader) {
return;
}
const deltaTime = Time.deltaTime;
this.updateAnchors(entities, deltaTime);
this.updateChunkRequests(entities, loader);
this._chunkManager.processLoads(loader.maxLoadsPerFrame);
this._chunkManager.processUnloads(loader.maxUnloadsPerFrame);
}
/**
* 更新锚点速度
*
* Update anchor velocities.
*/
private updateAnchors(entities: readonly Entity[], deltaTime: number): void {
for (const entity of entities) {
const anchor = entity.getComponent(StreamingAnchorComponent);
const transform = entity.getComponent(TransformComponent);
if (!anchor || !transform) continue;
const currentX = transform.position.x;
const currentY = transform.position.y;
if (deltaTime > 0) {
anchor.velocityX = (currentX - anchor.previousX) / deltaTime;
anchor.velocityY = (currentY - anchor.previousY) / deltaTime;
}
anchor.previousX = currentX;
anchor.previousY = currentY;
}
}
/**
* 更新区块加载/卸载请求
*
* Update chunk load/unload requests based on anchor positions.
*/
private updateChunkRequests(entities: readonly Entity[], loader: ChunkLoaderComponent): void {
if (!this._chunkManager) return;
const centerCoords: IChunkCoord[] = [];
for (const entity of entities) {
const transform = entity.getComponent(TransformComponent);
if (!transform) continue;
const coord = loader.worldToChunk(transform.position.x, transform.position.y);
centerCoords.push(coord);
const lastCoord = this._lastAnchorChunks.get(entity);
const hasMovedChunk = !lastCoord || lastCoord.x !== coord.x || lastCoord.y !== coord.y;
if (hasMovedChunk) {
this._lastAnchorChunks.set(entity, coord);
}
this.requestChunksForAnchor(entity, coord, loader);
}
this.requestUnloadsOutsideRange(centerCoords, loader);
}
/**
* 请求锚点周围的区块
*
* Request chunks around an anchor point.
*/
private requestChunksForAnchor(
entity: Entity,
centerCoord: IChunkCoord,
loader: ChunkLoaderComponent
): void {
if (!this._chunkManager) return;
const anchor = entity.getComponent(StreamingAnchorComponent);
if (!anchor) return;
const effectiveRadius = Math.ceil(loader.loadRadius * anchor.weight);
for (let dx = -effectiveRadius; dx <= effectiveRadius; dx++) {
for (let dy = -effectiveRadius; dy <= effectiveRadius; dy++) {
const coord = { x: centerCoord.x + dx, y: centerCoord.y + dy };
const distSq = dx * dx + dy * dy;
let priority: EChunkPriority;
if (distSq === 0) {
priority = EChunkPriority.Immediate;
} else if (distSq <= 1) {
priority = EChunkPriority.High;
} else if (distSq <= 4) {
priority = EChunkPriority.Normal;
} else {
priority = EChunkPriority.Low;
}
this._chunkManager.requestLoad(coord, priority);
this._chunkManager.cancelUnload(coord);
}
}
if (loader.bEnablePrefetch && anchor.bEnablePrefetch) {
this.requestPrefetchChunks(anchor, centerCoord, loader);
}
}
/**
* 请求预加载区块
*
* Request prefetch chunks based on movement direction.
*/
private requestPrefetchChunks(
anchor: StreamingAnchorComponent,
centerCoord: IChunkCoord,
loader: ChunkLoaderComponent
): void {
if (!this._chunkManager) return;
const velocityMagnitude = Math.sqrt(
anchor.velocityX * anchor.velocityX + anchor.velocityY * anchor.velocityY
);
if (velocityMagnitude < 10) return;
const dirX = anchor.velocityX / velocityMagnitude;
const dirY = anchor.velocityY / velocityMagnitude;
const chunkDirX = Math.round(dirX);
const chunkDirY = Math.round(dirY);
if (chunkDirX === 0 && chunkDirY === 0) return;
for (let i = 1; i <= loader.prefetchRadius; i++) {
const coord = {
x: centerCoord.x + chunkDirX * (loader.loadRadius + i),
y: centerCoord.y + chunkDirY * (loader.loadRadius + i)
};
this._chunkManager.requestLoad(coord, EChunkPriority.Prefetch);
}
}
/**
* 请求卸载超出范围的区块
*
* Request unload for chunks outside all anchors' ranges.
*/
private requestUnloadsOutsideRange(
centerCoords: IChunkCoord[],
loader: ChunkLoaderComponent
): void {
if (!this._chunkManager || centerCoords.length === 0) return;
const grid = (this._chunkManager as any)._chunkGrid;
if (!grid) return;
grid.forEach((_info: unknown, coord: IChunkCoord) => {
let isInRange = false;
for (const center of centerCoords) {
const dx = Math.abs(coord.x - center.x);
const dy = Math.abs(coord.y - center.y);
if (dx <= loader.unloadRadius && dy <= loader.unloadRadius) {
isInRange = true;
break;
}
}
if (!isInRange) {
this._chunkManager!.requestUnload(coord, loader.unloadDelay);
}
});
}
/**
* 查找 ChunkLoader 实体
*
* Find the chunk loader entity.
*/
private findLoaderEntity(scene: Scene): void {
const result = scene.queryAll(ChunkLoaderComponent);
if (result.entities.length > 0) {
this._loaderEntity = result.entities[0] as Entity;
}
}
/**
* 获取 ChunkLoaderComponent
*
* Get the chunk loader component.
*/
private getLoaderComponent(): ChunkLoaderComponent | null {
if (this._loaderEntity && !this._loaderEntity.isDestroyed) {
return this._loaderEntity.getComponent(ChunkLoaderComponent);
}
const scene = this.scene;
if (!scene) return null;
this.findLoaderEntity(scene);
return this._loaderEntity?.getComponent(ChunkLoaderComponent) ?? null;
}
}

View File

@@ -0,0 +1,2 @@
export { ChunkStreamingSystem } from './ChunkStreamingSystem';
export { ChunkCullingSystem } from './ChunkCullingSystem';

View File

@@ -0,0 +1,96 @@
import type { Entity } from '@esengine/ecs-framework';
import { EChunkState, EChunkPriority } from './ChunkState';
/**
* 区块坐标
*
* Chunk grid coordinates in world space.
*/
export interface IChunkCoord {
/** X 轴区块索引 | Chunk index on X axis */
readonly x: number;
/** Y 轴区块索引 | Chunk index on Y axis */
readonly y: number;
}
/**
* 区块边界
*
* World-space bounds of a chunk.
*/
export interface IChunkBounds {
/** 最小 X 坐标 | Minimum X coordinate */
readonly minX: number;
/** 最小 Y 坐标 | Minimum Y coordinate */
readonly minY: number;
/** 最大 X 坐标 | Maximum X coordinate */
readonly maxX: number;
/** 最大 Y 坐标 | Maximum Y coordinate */
readonly maxY: number;
}
/**
* 区块数据
*
* Serializable chunk data for storage and streaming.
*/
export interface IChunkData {
/** 区块坐标 | Chunk coordinates */
readonly coord: IChunkCoord;
/** 实体数据列表 | Serialized entity data */
readonly entities: ISerializedEntity[];
/** 区块元数据 | Chunk metadata */
readonly metadata?: Record<string, unknown>;
/** 数据版本 | Data version for migration */
readonly version: number;
}
/**
* 序列化实体数据
*
* Serialized entity format for chunk storage.
*/
export interface ISerializedEntity {
/** 实体名称 | Entity name */
name: string;
/** 组件数据 | Component data map */
components: Record<string, unknown>;
/** 相对于区块的局部位置 | Local position relative to chunk */
localPosition: { x: number; y: number };
}
/**
* 运行时区块信息
*
* Runtime chunk state and references.
*/
export interface IChunkInfo {
/** 区块坐标 | Chunk coordinates */
coord: IChunkCoord;
/** 当前状态 | Current state */
state: EChunkState;
/** 加载优先级 | Loading priority */
priority: EChunkPriority;
/** 区块内实体列表 | Entities within this chunk */
entities: Entity[];
/** 世界空间边界 | World-space bounds */
bounds: IChunkBounds;
/** 上次访问时间戳 | Last access timestamp for LRU */
lastAccessTime: number;
/** 距锚点的距离平方 | Squared distance to nearest anchor */
distanceSq: number;
}
/**
* 区块加载请求
*
* Request to load a chunk with priority.
*/
export interface IChunkLoadRequest {
/** 区块坐标 | Chunk coordinates */
coord: IChunkCoord;
/** 加载优先级 | Loading priority */
priority: EChunkPriority;
/** 请求时间戳 | Request timestamp */
timestamp: number;
}

View File

@@ -0,0 +1,35 @@
/**
* 区块状态枚举
*
* Chunk lifecycle states for streaming management.
*/
export const enum EChunkState {
/** 未加载 | Not loaded */
Unloaded = 'unloaded',
/** 加载中 | Loading in progress */
Loading = 'loading',
/** 已加载 | Fully loaded and ready */
Loaded = 'loaded',
/** 卸载中 | Unloading in progress */
Unloading = 'unloading',
/** 加载失败 | Failed to load */
Failed = 'failed'
}
/**
* 区块优先级
*
* Priority levels for chunk loading queue.
*/
export const enum EChunkPriority {
/** 立即加载 | Immediate loading required */
Immediate = 0,
/** 高优先级 | High priority */
High = 1,
/** 普通优先级 | Normal priority */
Normal = 2,
/** 低优先级 | Low priority */
Low = 3,
/** 预加载 | Prefetch for future use */
Prefetch = 4
}

View File

@@ -0,0 +1,76 @@
/**
* 世界流式加载配置
*
* Configuration for world streaming behavior.
*/
export interface IStreamingConfig {
/** 区块大小(世界单位)| Chunk size in world units */
chunkSize: number;
/**
* 加载半径(区块数)
*
* How many chunks to load around each anchor.
*/
loadRadius: number;
/**
* 卸载半径(区块数)
*
* Chunks beyond this radius will be unloaded.
* Should be greater than loadRadius to prevent thrashing.
*/
unloadRadius: number;
/**
* 每帧最大加载数
*
* Maximum chunks to load per frame to avoid stutter.
*/
maxLoadsPerFrame: number;
/**
* 每帧最大卸载数
*
* Maximum chunks to unload per frame.
*/
maxUnloadsPerFrame: number;
/**
* 最小卸载延迟(毫秒)
*
* Minimum time a chunk must be out of range before unloading.
* Prevents rapid load/unload cycles.
*/
unloadDelay: number;
/**
* 是否启用预加载
*
* Enable prefetching chunks in movement direction.
*/
bEnablePrefetch: boolean;
/**
* 预加载半径(区块数)
*
* Additional chunks to prefetch ahead of movement.
*/
prefetchRadius: number;
}
/**
* 默认流式加载配置
*
* Default streaming configuration values.
*/
export const DEFAULT_STREAMING_CONFIG: Readonly<IStreamingConfig> = {
chunkSize: 512,
loadRadius: 2,
unloadRadius: 4,
maxLoadsPerFrame: 2,
maxUnloadsPerFrame: 1,
unloadDelay: 3000,
bEnablePrefetch: true,
prefetchRadius: 1
};

View File

@@ -0,0 +1,11 @@
export { EChunkState, EChunkPriority } from './ChunkState';
export type {
IChunkCoord,
IChunkBounds,
IChunkData,
ISerializedEntity,
IChunkInfo,
IChunkLoadRequest
} from './ChunkData';
export type { IStreamingConfig } from './StreamingConfig';
export { DEFAULT_STREAMING_CONFIG } from './StreamingConfig';