From a2455e6734fb9dc05c6962212f63d4ada50805c9 Mon Sep 17 00:00:00 2001 From: RicardoDeZoete Date: Tue, 3 Mar 2026 15:48:08 +0100 Subject: [PATCH] Refactor ChunkManager and related classes for improved view distance handling and performance optimizations. Introduced new methods for batch visibility management and enhanced rendering efficiency in the Renderer class. Updated EntityManager to streamline dynamic entity updates and added new utility functions in GLTFManager for instanced mesh handling. --- client/src/chunks/ChunkManager.ts | 123 +++++- client/src/chunks/ChunkMeshManager.ts | 49 ++- client/src/core/Renderer.ts | 53 ++- client/src/entities/Entity.ts | 7 +- client/src/entities/EntityManager.ts | 58 ++- client/src/gltf/GLTFManager.ts | 361 ++++++++++++++---- .../postprocessing/SelectiveOutlinePass.ts | 164 +++++--- 7 files changed, 607 insertions(+), 208 deletions(-) diff --git a/client/src/chunks/ChunkManager.ts b/client/src/chunks/ChunkManager.ts index 505a9865..e5be3c2d 100644 --- a/client/src/chunks/ChunkManager.ts +++ b/client/src/chunks/ChunkManager.ts @@ -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'; @@ -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 = 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; @@ -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) => { @@ -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; } @@ -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; } @@ -229,6 +251,8 @@ export default class ChunkManager { this._game.chunkMeshManager.removeBatchTransparentSolidMesh(batchId); } + this._syncBatchVisibility(batchId); + // Update batch metadata this._registry.updateBatchMetadata(batchId, { blockCount, @@ -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; } -} \ No newline at end of file + + 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 = 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); + } + } +} diff --git a/client/src/chunks/ChunkMeshManager.ts b/client/src/chunks/ChunkMeshManager.ts index f125c708..088e8de0 100644 --- a/client/src/chunks/ChunkMeshManager.ts +++ b/client/src/chunks/ChunkMeshManager.ts @@ -33,6 +33,18 @@ export default class ChunkMeshManager { this._game = game; } + public get batchIds(): IterableIterator { + 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, material: Material): Mesh { const { positions, normals, uvs, indices, colors, lightLevels, foamLevels, foamLevelsDiag } = data; @@ -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[] { if (this._solidMeshesInSceneDirty) { this._solidMeshesInScene.length = 0; @@ -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++; } } -} \ No newline at end of file +} diff --git a/client/src/core/Renderer.ts b/client/src/core/Renderer.ts index bc742730..f262c6bf 100644 --- a/client/src/core/Renderer.ts +++ b/client/src/core/Renderer.ts @@ -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(); @@ -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); @@ -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(); } @@ -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 => { @@ -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 { @@ -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. diff --git a/client/src/entities/Entity.ts b/client/src/entities/Entity.ts index 3934acc0..809f7545 100644 --- a/client/src/entities/Entity.ts +++ b/client/src/entities/Entity.ts @@ -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'; @@ -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(); @@ -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++; diff --git a/client/src/entities/EntityManager.ts b/client/src/entities/EntityManager.ts index 5f9816db..b743c854 100644 --- a/client/src/entities/EntityManager.ts +++ b/client/src/entities/EntityManager.ts @@ -44,7 +44,9 @@ const DEFAULT_OUTLINE_OPTIONS: OutlineOptions = { export default class EntityManager { private _game: Game; private _entities: Map = new Map(); - private _dynamicEntities: Set = new Set(); + private _dynamicEntities: Set = new Set(); + private _dynamicEntityList: Entity[] = []; + private _dynamicEntityListDirty: boolean = false; private _outlines: Map = new Map(); private _outlineTargets: OutlineTarget[] = new Array(MAX_OUTLINES).fill(undefined).map(() => { return { object3d: null, options: null }; }); private _staticEnvironmentEntityManager: StaticEntityManager; @@ -62,6 +64,7 @@ export default class EntityManager { public get game(): Game { return this._game; } public get count(): number { return this._entities.size; } + public get hasOutlines(): boolean { return this._outlines.size > 0; } public get hasLightLevelVolumeUpdatedOnce(): boolean { return this._hasLightLevelVolumeUpdatedOnce; } public getEntity(id: number): Entity | StaticEntity | undefined { @@ -157,15 +160,30 @@ export default class EntityManager { ); } + private _getDynamicEntityList(): Entity[] { + if (!this._dynamicEntityListDirty) { + return this._dynamicEntityList; + } + + this._dynamicEntityList.length = 0; + for (const entity of this._dynamicEntities) { + this._dynamicEntityList.push(entity); + } + this._dynamicEntityListDirty = false; + + return this._dynamicEntityList; + } + private _onAnimate = (payload: RendererEventPayload.IAnimate): void => { EntityStats.reset(); EntityStats.count = this._entities.size; + const dynamicEntities = this._getDynamicEntityList(); // Entities are updated using a multi-pass approach. // First pass: Update local position and rotation - for (const entityId of this._dynamicEntities) { - this._entities.get(entityId)!.update(payload.frameDeltaS); + for (let i = 0; i < dynamicEntities.length; i++) { + dynamicEntities[i].update(payload.frameDeltaS); } // Second pass: Apply view distance. @@ -173,12 +191,13 @@ export default class EntityManager { // using the updated local position. if (this._game.settingsManager.qualityPerfTradeoff.viewDistance.enabled) { // View Distance handling. Also refer to the comment in ChunkManager - const viewDistanceSquared = Math.pow(this._game.renderer.viewDistance, 2); + const viewDistance = this._game.renderer.viewDistance; + const viewDistanceSquared = viewDistance * viewDistance; const cameraPos = this._game.camera.activeCamera.position; fromVec2.set(cameraPos.x, cameraPos.z); - for (const entityId of this._dynamicEntities) { - this._entities.get(entityId)!.applyViewDistance(viewDistanceSquared, fromVec2); + for (let i = 0; i < dynamicEntities.length; i++) { + dynamicEntities[i].applyViewDistance(viewDistanceSquared, fromVec2); } } else { // If ViewDistance can be toggled dynamically in the future, we need to @@ -190,21 +209,21 @@ export default class EntityManager { const camera = this._game.camera.activeCamera; frustum.setFromProjectionMatrix(projScreenMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse)); - for (const entityId of this._dynamicEntities) { - this._entities.get(entityId)!.applyFrustumCulling(frustum); + for (let i = 0; i < dynamicEntities.length; i++) { + dynamicEntities[i].applyFrustumCulling(frustum); } // Forth pass: Update Animation and Local matrix const frameCount = this._game.performanceMetricsManager.frameCount; - for (const entityId of this._dynamicEntities) { - this._entities.get(entityId)!.updateAnimationAndLocalMatrix(payload.frameDeltaS, frameCount); + for (let i = 0; i < dynamicEntities.length; i++) { + dynamicEntities[i].updateAnimationAndLocalMatrix(payload.frameDeltaS, frameCount); } // Fifth pass: World matrices update. // Considering parent-child relationships, the WorldMatrix must be updated only // after the LocalMatrix of all entities has been updated. - for (const entityId of this._dynamicEntities) { - this._entities.get(entityId)!.updateWorldMatrices(this._hasLightLevelVolumeUpdatedOnce); + for (let i = 0; i < dynamicEntities.length; i++) { + dynamicEntities[i].updateWorldMatrices(this._hasLightLevelVolumeUpdatedOnce); } // Sixth pass: Light level update @@ -212,8 +231,8 @@ export default class EntityManager { // Blocks are probably not placed at all. Therefore, detects whether a Light Level Volume has ever been // generated by a Light Emission Block is placed, and only then perform Light Level update processing. if (this._hasLightLevelVolumeUpdatedOnce) { - for (const entityId of this._dynamicEntities) { - this._entities.get(entityId)!.updateLightLevel(this._needsLightLevelRefresh); + for (let i = 0; i < dynamicEntities.length; i++) { + dynamicEntities[i].updateLightLevel(this._needsLightLevelRefresh); } if (this._needsLightLevelRefresh) { this._staticEnvironmentEntityManager.updateLightLevel(); @@ -223,8 +242,8 @@ export default class EntityManager { // Seventh pass: Sky light update // Sky light is always available and doesn't depend on light emission blocks - for (const entityId of this._dynamicEntities) { - this._entities.get(entityId)!.updateSkyLight(this._needsSkyLightRefresh, payload.frameDeltaS); + for (let i = 0; i < dynamicEntities.length; i++) { + dynamicEntities[i].updateSkyLight(this._needsSkyLightRefresh, payload.frameDeltaS); } if (this._needsSkyLightRefresh) { this._staticEnvironmentEntityManager.updateSkyLight(); @@ -296,7 +315,8 @@ export default class EntityManager { } else { const shouldSuppressAnimations = deserializedEntity.isEnvironmental ? this._shouldSuppressEnvironmentAnimations : false; entity = new Entity(this._game, entityData, shouldSuppressAnimations); - this._dynamicEntities.add(entity.id); + this._dynamicEntities.add(entity); + this._dynamicEntityListDirty = true; } this._entities.set(entity.id, entity); @@ -320,7 +340,9 @@ export default class EntityManager { entity.release(); this._entities.delete(entity.id); - this._dynamicEntities.delete(entity.id); + if (entity instanceof Entity && this._dynamicEntities.delete(entity)) { + this._dynamicEntityListDirty = true; + } this._outlines.delete(entity.id); } diff --git a/client/src/gltf/GLTFManager.ts b/client/src/gltf/GLTFManager.ts index d087456a..badac9ce 100644 --- a/client/src/gltf/GLTFManager.ts +++ b/client/src/gltf/GLTFManager.ts @@ -47,6 +47,9 @@ const USE_INSTANCED_MESH_THRESHOLD = 8; // Note: This only affects rendering method selection, not InstancedMesh creation. // InstancedMesh pairs are still created based on USE_INSTANCED_MESH_THRESHOLD. const USE_INSTANCED_MESH_THRESHOLD_TRANSPARENT = 4; +const TRANSPARENT_INSTANCED_SORT_INTERVAL_SMALL = 2; +const TRANSPARENT_INSTANCED_SORT_INTERVAL_MEDIUM = 3; +const TRANSPARENT_INSTANCED_SORT_INTERVAL_LARGE = 4; // Creates or deletes an appropriately sized InstancedMesh when the number of Cloned Meshes exceeds the thresholds. // There is a difference between the thresholds for creating and deleting. @@ -62,6 +65,8 @@ const INSTANCED_TEXTURE_RESIZE_DECREASE_FACTOR = 8; // TODO: Should this variable be defined in a more appropriate location since it can be used more generically? const DEFAULT_LAYER = 0; +const DEFAULT_LAYER_MASK = 1 << DEFAULT_LAYER; +const USERDATA_TRANSPARENT_SORT_DEPTH = 'gltfTransparentSortDepth'; const USE_INSTANCED_COLOR_DEFINE = 'USE_INSTANCED_COLOR'; @@ -87,10 +92,9 @@ const WORLD_NORMAL_Y_VARYING = 'vWorldNormalY'; const UNIFORM_RAW_AMBIENT_LIGHT_COLOR = 'rawAmbientLightColor'; const UNIFORM_AMBIENT_LIGHT_INTENSITY = 'ambientLightIntensity'; +const MATRIX4_COMPONENT_COUNT = 16; // Working variables -const attributes: InstancedBufferAttribute[] = []; -const clonedMeshArray: Mesh[] = []; const opaqueClonedMeshes: Mesh[] = []; const transparentClonedMeshes: Mesh[] = []; const usedColorTextureSet: Set = new Set(); @@ -590,6 +594,8 @@ type InstancedMeshPair = { type InstancedMeshUsageState = { prevOpaqueIndex: number; prevTransparentIndex: number; + lastTransparentSortFrame: number; + lastTransparentSortCount: number; }; type SourceMeshAttributeCounters = { @@ -776,7 +782,12 @@ export default class GLTFManager { // Layer management is handled in update() method instancedMeshPairs = []; entry.sourceToInstancedMeshes.set(sourceMesh, instancedMeshPairs); - entry.sourceToInstancedMeshUsageState.set(sourceMesh, { prevOpaqueIndex: -1, prevTransparentIndex: -1 }); + entry.sourceToInstancedMeshUsageState.set(sourceMesh, { + prevOpaqueIndex: -1, + prevTransparentIndex: -1, + lastTransparentSortFrame: -1, + lastTransparentSortCount: -1, + }); opaqueMaterial = new InstancedMeshBasicMaterial(sourceMesh.material as EmissiveMeshBasicMaterial, this._game); opaqueMaterial.transparent = false; @@ -1113,7 +1124,7 @@ export default class GLTFManager { entry.sourceToInstancedMeshes.delete(sourceMesh); clonedMeshSet.forEach(clonedMesh => { - clonedMesh.layers.enable(DEFAULT_LAYER); + this._setClonedMeshDefaultLayerEnabled(clonedMesh, true); }); entry.needsInstancedTextureRefresh = true; @@ -1249,8 +1260,41 @@ export default class GLTFManager { } const targetPair = instancedMeshPairs[instancedMeshIndex]; const instancedMesh = isTransparent ? targetPair.transparent : targetPair.opaque; + const instanceMatrixAttribute = instancedMesh.instanceMatrix; + const instanceMatrixArray = instanceMatrixAttribute.array as Float32Array; + const instanceSkyLightAttribute = instancedMesh.geometry.getAttribute(INSTANCE_SKY_LIGHT_ATTRIBUTE)! as InstancedBufferAttribute; + const instanceSkyLightArray = instanceSkyLightAttribute.array as Float32Array; + const instanceColorArray = needsColorAttribute ? instancedMesh.instanceColor!.array as Float32Array : null; + const opacityArray = needsOpacityAttribute + ? (instancedMesh.geometry.getAttribute(INSTANCE_OPACITY_ATTRIBUTE)! as InstancedBufferAttribute).array as Float32Array + : null; + const lightLevelArray = needsLightLevelAttribute + ? (instancedMesh.geometry.getAttribute(INSTANCE_LIGHT_LEVEL_ATTRIBUTE)! as InstancedBufferAttribute).array as Float32Array + : null; + const emissiveArray = needsEmissiveAttribute + ? (instancedMesh.geometry.getAttribute(INSTANCE_EMISSIVE_ATTRIBUTE)! as InstancedBufferAttribute).array as Float32Array + : null; + const oldCount = instancedMesh.count; let index = 0; + type DirtyRange = [number, number]; + const createDirtyRange = (): DirtyRange => [Number.POSITIVE_INFINITY, -1]; + const markDirty = (instanceIndex: number, dirtyRange: DirtyRange): void => { + if (instanceIndex < dirtyRange[0]) dirtyRange[0] = instanceIndex; + if (instanceIndex > dirtyRange[1]) dirtyRange[1] = instanceIndex; + }; + const extendDirty = (dirtyRange: DirtyRange, startIndex: number, endIndex: number): void => { + if (endIndex < startIndex) return; + if (startIndex < dirtyRange[0]) dirtyRange[0] = startIndex; + if (endIndex > dirtyRange[1]) dirtyRange[1] = endIndex; + }; + const matrixDirtyRange = createDirtyRange(); + const skyLightDirtyRange = createDirtyRange(); + const colorDirtyRange = createDirtyRange(); + const opacityDirtyRange = createDirtyRange(); + const lightLevelDirtyRange = createDirtyRange(); + const emissiveDirtyRange = createDirtyRange(); + const mapIndexDirtyRange = createDirtyRange(); for (const clonedMesh of clonedMeshes) { // Accessing all cloned meshes every animation frame, copying necessary data, and transferring @@ -1269,113 +1313,182 @@ export default class GLTFManager { // Assumes that the InstancedMesh is directly under the Scene and // its World Matrix is an Identity Matrix. - instancedMesh.setMatrixAt(index, clonedMesh.matrixWorld); - instancedMesh.geometry.getAttribute(INSTANCE_SKY_LIGHT_ATTRIBUTE)!.setX( - index, - Entity.getEffectiveSkyLight(this._game, clonedMesh), - ); + const matrixElements = clonedMesh.matrixWorld.elements; + const matrixArrayOffset = index * MATRIX4_COMPONENT_COUNT; + let matrixChanged = false; + for (let i = 0; i < MATRIX4_COMPONENT_COUNT; i++) { + if (instanceMatrixArray[matrixArrayOffset + i] !== matrixElements[i]) { + matrixChanged = true; + break; + } + } + if (matrixChanged) { + for (let i = 0; i < MATRIX4_COMPONENT_COUNT; i++) { + instanceMatrixArray[matrixArrayOffset + i] = matrixElements[i]; + } + markDirty(index, matrixDirtyRange); + } - clonedMeshArray[index] = clonedMesh; - index++; - } + const skyLight = Entity.getEffectiveSkyLight(this._game, clonedMesh); + if (instanceSkyLightArray[index] !== skyLight) { + instanceSkyLightArray[index] = skyLight; + markDirty(index, skyLightDirtyRange); + } - if (needsColorAttribute) { - for (let i = 0; i < index; i++) { - const clonedMesh = clonedMeshArray[i]; - const material = clonedMesh.material as EmissiveMeshBasicMaterial; - instancedMesh.setColorAt(i, material.color); + if (instanceColorArray) { + const colorArrayOffset = index * 3; + if ( + instanceColorArray[colorArrayOffset] !== material.color.r || + instanceColorArray[colorArrayOffset + 1] !== material.color.g || + instanceColorArray[colorArrayOffset + 2] !== material.color.b + ) { + instanceColorArray[colorArrayOffset] = material.color.r; + instanceColorArray[colorArrayOffset + 1] = material.color.g; + instanceColorArray[colorArrayOffset + 2] = material.color.b; + markDirty(index, colorDirtyRange); + } } - } - if (needsOpacityAttribute) { - const opacityAttribute = instancedMesh.geometry.getAttribute(INSTANCE_OPACITY_ATTRIBUTE)!; - for (let i = 0; i < index; i++) { - const clonedMesh = clonedMeshArray[i]; - const material = clonedMesh.material as EmissiveMeshBasicMaterial; - opacityAttribute.setX(i, material.opacity); + if (opacityArray && opacityArray[index] !== material.opacity) { + opacityArray[index] = material.opacity; + markDirty(index, opacityDirtyRange); } - } - if (needsLightLevelAttribute) { - const lightLevelAttribute = instancedMesh.geometry.getAttribute(INSTANCE_LIGHT_LEVEL_ATTRIBUTE)!; - for (let i = 0; i < index; i++) { - const clonedMesh = clonedMeshArray[i]; - lightLevelAttribute.setX(i, Entity.getEffectiveLightLevel(this._game, clonedMesh)); + if (lightLevelArray) { + const lightLevel = Entity.getEffectiveLightLevel(this._game, clonedMesh); + if (lightLevelArray[index] !== lightLevel) { + lightLevelArray[index] = lightLevel; + markDirty(index, lightLevelDirtyRange); + } } - } - if (needsEmissiveAttribute) { - const emissiveAttribute = instancedMesh.geometry.getAttribute(INSTANCE_EMISSIVE_ATTRIBUTE)!; - for (let i = 0; i < index; i++) { - const clonedMesh = clonedMeshArray[i]; - const material = clonedMesh.material as EmissiveMeshBasicMaterial; + if (emissiveArray) { + const emissiveArrayOffset = index * 4; // vec4: rgb = emissive color, a = emissive intensity - emissiveAttribute.setXYZW( - i, - material.customEmissive.r, - material.customEmissive.g, - material.customEmissive.b, - material.customEmissiveIntensity, - ); + if ( + emissiveArray[emissiveArrayOffset] !== material.customEmissive.r || + emissiveArray[emissiveArrayOffset + 1] !== material.customEmissive.g || + emissiveArray[emissiveArrayOffset + 2] !== material.customEmissive.b || + emissiveArray[emissiveArrayOffset + 3] !== material.customEmissiveIntensity + ) { + emissiveArray[emissiveArrayOffset] = material.customEmissive.r; + emissiveArray[emissiveArrayOffset + 1] = material.customEmissive.g; + emissiveArray[emissiveArrayOffset + 2] = material.customEmissive.b; + emissiveArray[emissiveArrayOffset + 3] = material.customEmissiveIntensity; + markDirty(index, emissiveDirtyRange); + } } + + index++; } instancedMesh.count = index; - clonedMeshArray.length = 0; if (index > 0) { - this._game.renderer.addToScene(instancedMesh); let useInstancedTexture = false; + let nextColorTexture: Texture | null = null; if (usedColorTextureSet.size === 0) { - instancedMesh.setColorTexture(null); + nextColorTexture = null; } else if (usedColorTextureSet.size === 1) { - instancedMesh.setColorTexture(usedColorTextureSet.values().next().value!) + nextColorTexture = usedColorTextureSet.values().next().value!; } else { const instancedTexture = entry.sourceTextureToInstancedTextures.get(sourceMaterial.map!)!; + const mapIndexAttribute = instancedMesh.geometry.getAttribute(INSTANCE_MAP_INDEX_ATTRIBUTE)! as InstancedBufferAttribute; + const mapIndexArray = mapIndexAttribute.array as Float32Array; for (let meshIndex = 0; meshIndex < clonedMeshes.length; meshIndex++) { const clonedMesh = clonedMeshes[meshIndex]; const material = clonedMesh.material as EmissiveMeshBasicMaterial; const mapIndex = instancedTexture.getIndex(material.map!.source); - instancedMesh.geometry.getAttribute(INSTANCE_MAP_INDEX_ATTRIBUTE)!.setX(meshIndex, mapIndex); + if (mapIndexArray[meshIndex] !== mapIndex) { + mapIndexArray[meshIndex] = mapIndex; + markDirty(meshIndex, mapIndexDirtyRange); + } } - instancedMesh.setColorTexture(instancedTexture); + nextColorTexture = instancedTexture; useInstancedTexture = true; } - attributes.push(instancedMesh.instanceMatrix); + if (instancedMesh.material.map !== nextColorTexture) { + instancedMesh.setColorTexture(nextColorTexture); + } + + const markAttributeNeedsUpdate = ( + attribute: InstancedBufferAttribute, + dirtyRange: DirtyRange, + ): void => { + if (dirtyRange[1] < dirtyRange[0]) { + return; + } + + attribute.clearUpdateRanges(); + attribute.addUpdateRange( + dirtyRange[0] * attribute.itemSize, + (dirtyRange[1] - dirtyRange[0] + 1) * attribute.itemSize, + ); + attribute.needsUpdate = true; + GLTFStats.attributeElementsUpdated += (dirtyRange[1] - dirtyRange[0] + 1) * attribute.itemSize; + }; + + // When instance count increases, initialize values for newly visible tail entries. + if (index > oldCount) { + const tailStart = oldCount; + const tailEnd = index - 1; + extendDirty(matrixDirtyRange, tailStart, tailEnd); + extendDirty(skyLightDirtyRange, tailStart, tailEnd); + if (needsColorAttribute) { + extendDirty(colorDirtyRange, tailStart, tailEnd); + } + if (needsOpacityAttribute) { + extendDirty(opacityDirtyRange, tailStart, tailEnd); + } + if (needsLightLevelAttribute) { + extendDirty(lightLevelDirtyRange, tailStart, tailEnd); + } + if (needsEmissiveAttribute) { + extendDirty(emissiveDirtyRange, tailStart, tailEnd); + } + if (useInstancedTexture) { + extendDirty(mapIndexDirtyRange, tailStart, tailEnd); + } + } + + markAttributeNeedsUpdate(instanceMatrixAttribute, matrixDirtyRange); + markAttributeNeedsUpdate(instanceSkyLightAttribute, skyLightDirtyRange); if (needsColorAttribute) { - attributes.push(instancedMesh.instanceColor!); + markAttributeNeedsUpdate(instancedMesh.instanceColor!, colorDirtyRange); } if (needsOpacityAttribute) { - attributes.push(instancedMesh.geometry.getAttribute(INSTANCE_OPACITY_ATTRIBUTE)! as InstancedBufferAttribute); + markAttributeNeedsUpdate( + instancedMesh.geometry.getAttribute(INSTANCE_OPACITY_ATTRIBUTE)! as InstancedBufferAttribute, + opacityDirtyRange, + ); } if (needsLightLevelAttribute) { - attributes.push(instancedMesh.geometry.getAttribute(INSTANCE_LIGHT_LEVEL_ATTRIBUTE)! as InstancedBufferAttribute); + markAttributeNeedsUpdate( + instancedMesh.geometry.getAttribute(INSTANCE_LIGHT_LEVEL_ATTRIBUTE)! as InstancedBufferAttribute, + lightLevelDirtyRange, + ); } - attributes.push(instancedMesh.geometry.getAttribute(INSTANCE_SKY_LIGHT_ATTRIBUTE)! as InstancedBufferAttribute); - if (needsEmissiveAttribute) { - attributes.push(instancedMesh.geometry.getAttribute(INSTANCE_EMISSIVE_ATTRIBUTE)! as InstancedBufferAttribute); + markAttributeNeedsUpdate( + instancedMesh.geometry.getAttribute(INSTANCE_EMISSIVE_ATTRIBUTE)! as InstancedBufferAttribute, + emissiveDirtyRange, + ); } if (useInstancedTexture) { - attributes.push(instancedMesh.geometry.getAttribute(INSTANCE_MAP_INDEX_ATTRIBUTE)! as InstancedBufferAttribute); - } - - for (const attribute of attributes) { - attribute.clearUpdateRanges(); - attribute.addUpdateRange(0, index * attribute.itemSize); - attribute.needsUpdate = true; - GLTFStats.attributeElementsUpdated += index * attribute.itemSize; + markAttributeNeedsUpdate( + instancedMesh.geometry.getAttribute(INSTANCE_MAP_INDEX_ATTRIBUTE)! as InstancedBufferAttribute, + mapIndexDirtyRange, + ); } - attributes.length = 0; // Since the Frustum is slightly enlarged, this measurement is not // entirely accurate, but it should be taken only as a rough reference. @@ -1385,6 +1498,61 @@ export default class GLTFManager { return index > 0 ? instancedMeshIndex : -1; } + private _setInstancedMeshInScene(instancedMesh: InstancedMeshEx, inScene: boolean): void { + if (inScene) { + if (instancedMesh.parent === null) { + this._game.renderer.addToScene(instancedMesh); + } + return; + } + + if (instancedMesh.parent !== null) { + this._game.renderer.removeFromScene(instancedMesh); + } + } + + private _setClonedMeshDefaultLayerEnabled(clonedMesh: Mesh, enabled: boolean): void { + const isEnabled = (clonedMesh.layers.mask & DEFAULT_LAYER_MASK) !== 0; + + if (isEnabled === enabled) { + return; + } + + if (enabled) { + clonedMesh.layers.enable(DEFAULT_LAYER); + } else { + clonedMesh.layers.disable(DEFAULT_LAYER); + } + } + + private _getTransparentSortInterval(visibleTransparentCount: number): number { + if (visibleTransparentCount >= 256) { + return TRANSPARENT_INSTANCED_SORT_INTERVAL_LARGE; + } + if (visibleTransparentCount >= 96) { + return TRANSPARENT_INSTANCED_SORT_INTERVAL_MEDIUM; + } + return TRANSPARENT_INSTANCED_SORT_INTERVAL_SMALL; + } + + private _sortTransparentClonedMeshesByDepth(clonedMeshes: Mesh[]): void { + const cameraPos = this._game.camera.activeCamera.position; + const viewDir = this._game.camera.activeViewDir; + + for (let i = 0; i < clonedMeshes.length; i++) { + const mesh = clonedMeshes[i]; + const e = mesh.matrixWorld.elements; + // Project world position to camera forward direction (farther first for alpha blending). + const depth = + (e[12] - cameraPos.x) * viewDir.x + + (e[13] - cameraPos.y) * viewDir.y + + (e[14] - cameraPos.z) * viewDir.z; + mesh.userData[USERDATA_TRANSPARENT_SORT_DEPTH] = depth; + } + + clonedMeshes.sort((a, b) => b.userData[USERDATA_TRANSPARENT_SORT_DEPTH] - a.userData[USERDATA_TRANSPARENT_SORT_DEPTH]); + } + // Reflect the world matrices, color, and opacity of the cloned Meshes onto the InstancedMesh. // Assumes that the world matrix of the entire scene is up-to-date and called before // the render call. @@ -1409,24 +1577,14 @@ export default class GLTFManager { const clonedMeshSet = entry.sourceToClonedMeshSet.get(sourceMesh)!; const usage = entry.sourceToInstancedMeshUsageState.get(sourceMesh)!; - if (usage.prevOpaqueIndex >= 0) { - if (usage.prevOpaqueIndex < instancedMeshPairs.length) { - this._game.renderer.removeFromScene(instancedMeshPairs[usage.prevOpaqueIndex].opaque); - } - usage.prevOpaqueIndex = -1; - } - if (usage.prevTransparentIndex >= 0) { - if (usage.prevTransparentIndex < instancedMeshPairs.length) { - this._game.renderer.removeFromScene(instancedMeshPairs[usage.prevTransparentIndex].transparent); - } - usage.prevTransparentIndex = -1; - } + const prevOpaqueIndex = usage.prevOpaqueIndex; + const prevTransparentIndex = usage.prevTransparentIndex; // In the first pass, disable all cloned mesh layers and search for visible ones for (const clonedMesh of clonedMeshSet) { // Disable layer for all cloned meshes (will be re-enabled for transparent meshes if needed) if (instancedMeshPairs) { - clonedMesh.layers.disable(DEFAULT_LAYER); + this._setClonedMeshDefaultLayerEnabled(clonedMesh, false); } if (!Entity.isNodeEffectivelyVisible(this._game, clonedMesh)) { @@ -1441,7 +1599,7 @@ export default class GLTFManager { } // Process opaque meshes - usage.prevOpaqueIndex = this._processClonedMeshes( + const nextOpaqueIndex = this._processClonedMeshes( opaqueClonedMeshes, instancedMeshPairs, false, @@ -1461,25 +1619,60 @@ export default class GLTFManager { // Use regular mesh rendering for better transparency sorting // Re-enable layers for visible transparent meshes for (const clonedMesh of transparentClonedMeshes) { - clonedMesh.layers.enable(DEFAULT_LAYER); + this._setClonedMeshDefaultLayerEnabled(clonedMesh, true); } + // Force a fresh sort if we switch back to instanced transparent rendering later. + usage.lastTransparentSortFrame = -1; + usage.lastTransparentSortCount = -1; // Transparent InstancedMesh will remain invisible } else { // Use InstancedMesh for better performance // Layers are already disabled in the first pass - // TODO: Consider sorting instances by distance from camera before updating instance attributes. - // This could improve rendering of overlapping transparent objects within the same InstancedMesh - // by ensuring back-to-front rendering order. + // Sort periodically by projected depth to improve overlapping transparency quality. + const frameCount = this._game.performanceMetricsManager.frameCount; + const sortInterval = this._getTransparentSortInterval(transparentClonedMeshes.length); + if ( + usage.lastTransparentSortFrame < 0 || + usage.lastTransparentSortCount !== transparentClonedMeshes.length || + frameCount - usage.lastTransparentSortFrame >= sortInterval + ) { + this._sortTransparentClonedMeshesByDepth(transparentClonedMeshes); + usage.lastTransparentSortFrame = frameCount; + usage.lastTransparentSortCount = transparentClonedMeshes.length; + } - usage.prevTransparentIndex = this._processClonedMeshes( + const nextTransparentIndex = this._processClonedMeshes( transparentClonedMeshes, instancedMeshPairs, true, entry, sourceMesh ); + + if (prevTransparentIndex >= 0 && prevTransparentIndex < instancedMeshPairs.length && prevTransparentIndex !== nextTransparentIndex) { + this._setInstancedMeshInScene(instancedMeshPairs[prevTransparentIndex].transparent, false); + } + if (nextTransparentIndex >= 0 && nextTransparentIndex < instancedMeshPairs.length) { + this._setInstancedMeshInScene(instancedMeshPairs[nextTransparentIndex].transparent, true); + } + usage.prevTransparentIndex = nextTransparentIndex; + } + + if (transparentClonedMeshes.length <= USE_INSTANCED_MESH_THRESHOLD_TRANSPARENT) { + if (prevTransparentIndex >= 0 && prevTransparentIndex < instancedMeshPairs.length) { + this._setInstancedMeshInScene(instancedMeshPairs[prevTransparentIndex].transparent, false); + } + usage.prevTransparentIndex = -1; + } + + if (prevOpaqueIndex >= 0 && prevOpaqueIndex < instancedMeshPairs.length && prevOpaqueIndex !== nextOpaqueIndex) { + this._setInstancedMeshInScene(instancedMeshPairs[prevOpaqueIndex].opaque, false); + } + if (nextOpaqueIndex >= 0 && nextOpaqueIndex < instancedMeshPairs.length) { + this._setInstancedMeshInScene(instancedMeshPairs[nextOpaqueIndex].opaque, true); } + usage.prevOpaqueIndex = nextOpaqueIndex; opaqueClonedMeshes.length = 0; transparentClonedMeshes.length = 0; diff --git a/client/src/three/postprocessing/SelectiveOutlinePass.ts b/client/src/three/postprocessing/SelectiveOutlinePass.ts index 955372d6..fa2a84fe 100644 --- a/client/src/three/postprocessing/SelectiveOutlinePass.ts +++ b/client/src/three/postprocessing/SelectiveOutlinePass.ts @@ -381,11 +381,18 @@ interface TargetEntry { targetIndex: number; } +type MaskMaterialVariants = { + none: ShaderMaterial; + map: ShaderMaterial; + alphaMap: ShaderMaterial; + mapAndAlphaMap: ShaderMaterial; +}; + export class SelectiveOutlinePass extends Pass { private _camera: PerspectiveCamera; private _fsQuad: FullScreenQuad; - private _maskMaterial: ShaderMaterial; - private _maskNoDepthMaterial: ShaderMaterial; + private _occludedMaskMaterials: MaskMaterialVariants; + private _nonOccludedMaskMaterials: MaskMaterialVariants; private _occludedMaskRenderTarget: WebGLRenderTarget; private _nonOccludedMaskRenderTarget: WebGLRenderTarget; private _outlineMaterial: ShaderMaterial; @@ -423,21 +430,8 @@ export class SelectiveOutlinePass extends Pass { }, ); - this._maskMaterial = new ShaderMaterial({ - uniforms: UniformsUtils.clone(MaskShader.uniforms), - vertexShader: MaskShader.vertexShader, - fragmentShader: MaskShader.fragmentShader, - defines: {}, - depthTest: true, - }); - - this._maskNoDepthMaterial = new ShaderMaterial({ - uniforms: UniformsUtils.clone(MaskNoDepthShader.uniforms), - vertexShader: MaskNoDepthShader.vertexShader, - fragmentShader: MaskNoDepthShader.fragmentShader, - defines: {}, - depthTest: true, - }); + this._occludedMaskMaterials = this._createMaskMaterialVariants(MaskShader, true); + this._nonOccludedMaskMaterials = this._createMaskMaterialVariants(MaskNoDepthShader, true); this._outlineMaterial = new ShaderMaterial({ uniforms: UniformsUtils.clone(OutlineShader.uniforms), @@ -476,9 +470,69 @@ export class SelectiveOutlinePass extends Pass { this.enabled = false; } - // NOTE: This callback updates material defines per-mesh and sets needsUpdate = true. - // We rely on Three.js internally caching compiled shader programs by defines combination, - // so only the first occurrence of each combination triggers compilation. + private _createMaskMaterialVariants(shader: typeof MaskShader | typeof MaskNoDepthShader, depthTest: boolean): MaskMaterialVariants { + const create = (useMap: boolean, useAlphaMap: boolean): ShaderMaterial => { + const defines: Record = {}; + + if (useMap) { + defines[DEFINE_USE_MAP] = ''; + } + + if (useAlphaMap) { + defines[DEFINE_USE_ALPHA_MAP] = ''; + } + + return new ShaderMaterial({ + uniforms: UniformsUtils.clone(shader.uniforms), + vertexShader: shader.vertexShader, + fragmentShader: shader.fragmentShader, + defines, + depthTest, + }); + }; + + return { + none: create(false, false), + map: create(true, false), + alphaMap: create(false, true), + mapAndAlphaMap: create(true, true), + }; + } + + private _setMaskMaterialUniforms(maskMaterials: MaskMaterialVariants, depthTexture: DepthTexture | null): void { + const materials = [ + maskMaterials.none, + maskMaterials.map, + maskMaterials.alphaMap, + maskMaterials.mapAndAlphaMap, + ]; + + for (const material of materials) { + if (UNIFORM_T_DEPTH in material.uniforms) { + material.uniforms[UNIFORM_T_DEPTH].value = depthTexture; + } + if (UNIFORM_CAMERA_NEAR in material.uniforms) { + material.uniforms[UNIFORM_CAMERA_NEAR].value = this._camera.near; + } + if (UNIFORM_CAMERA_FAR in material.uniforms) { + material.uniforms[UNIFORM_CAMERA_FAR].value = this._camera.far; + } + } + } + + private _selectMaskMaterial(maskMaterials: MaskMaterialVariants, useMap: boolean, useAlphaMap: boolean): ShaderMaterial { + if (useMap && useAlphaMap) { + return maskMaterials.mapAndAlphaMap; + } + if (useMap) { + return maskMaterials.map; + } + if (useAlphaMap) { + return maskMaterials.alphaMap; + } + return maskMaterials.none; + } + private _onBeforeRender = function( this: Mesh, _renderer: never, @@ -487,61 +541,48 @@ export class SelectiveOutlinePass extends Pass { _geometry: never, material: ShaderMaterial, ): void { - const map = this.userData[USERDATA_OUTLINE_MAP]; - const alphaMap = this.userData[USERDATA_OUTLINE_ALPHA_MAP]; - const useMap = !!map; - const useAlphaMap = !!alphaMap; - - // Update defines if needed - const definesChanged = - (useMap !== (DEFINE_USE_MAP in material.defines)) || - (useAlphaMap !== (DEFINE_USE_ALPHA_MAP in material.defines)); - - if (definesChanged) { - if (useMap) { - material.defines[DEFINE_USE_MAP] = ''; - } else { - delete material.defines[DEFINE_USE_MAP]; - } - if (useAlphaMap) { - material.defines[DEFINE_USE_ALPHA_MAP] = ''; - } else { - delete material.defines[DEFINE_USE_ALPHA_MAP]; - } - material.needsUpdate = true; - } - material.uniforms[UNIFORM_TARGET_INDEX].value = this.userData[USERDATA_OUTLINE_TARGET_INDEX] + 1; - material.uniforms[UNIFORM_T_MAP].value = map ?? null; - material.uniforms[UNIFORM_T_ALPHA_MAP].value = alphaMap ?? null; + material.uniforms[UNIFORM_T_MAP].value = this.userData[USERDATA_OUTLINE_MAP] ?? null; + material.uniforms[UNIFORM_T_ALPHA_MAP].value = this.userData[USERDATA_OUTLINE_ALPHA_MAP] ?? null; material.uniforms[UNIFORM_ALPHA_TEST].value = this.userData[USERDATA_OUTLINE_ALPHA_TEST] ?? 0.0; material.uniforms[UNIFORM_OPACITY].value = this.userData[USERDATA_OUTLINE_OPACITY] ?? 1.0; material.side = this.userData[USERDATA_OUTLINE_SIDE]; material.uniformsNeedUpdate = true; }; - private _traverseAndSetupMaterials = (obj: Object3D, maskMaterial: ShaderMaterial, targetIndex: number): void => { + private _traverseAndSetupMaterials = (obj: Object3D, maskMaterials: MaskMaterialVariants, targetIndex: number): void => { if (obj instanceof Mesh) { originalMaterials.set(obj, obj.material); originalOnBeforeRenders.set(obj, obj.onBeforeRender); // Carry over alpha test, map, alphaMap, opacity, and side from original material const origMat = obj.material as any; - if (origMat.alphaTest > 0) { + const alphaTest = origMat.alphaTest ?? 0.0; + if (alphaTest > 0) { // Only set textures/opacity if alphaTest is enabled (matches Three.js behavior) obj.userData[USERDATA_OUTLINE_MAP] = origMat.map ?? null; obj.userData[USERDATA_OUTLINE_ALPHA_MAP] = origMat.alphaMap ?? null; - obj.userData[USERDATA_OUTLINE_ALPHA_TEST] = origMat.alphaTest; + obj.userData[USERDATA_OUTLINE_ALPHA_TEST] = alphaTest; obj.userData[USERDATA_OUTLINE_OPACITY] = origMat.opacity ?? 1.0; + } else { + obj.userData[USERDATA_OUTLINE_MAP] = null; + obj.userData[USERDATA_OUTLINE_ALPHA_MAP] = null; + obj.userData[USERDATA_OUTLINE_ALPHA_TEST] = 0.0; + obj.userData[USERDATA_OUTLINE_OPACITY] = 1.0; } obj.userData[USERDATA_OUTLINE_SIDE] = origMat.side; + const maskMaterial = this._selectMaskMaterial( + maskMaterials, + !!obj.userData[USERDATA_OUTLINE_MAP], + !!obj.userData[USERDATA_OUTLINE_ALPHA_MAP], + ); obj.material = maskMaterial; obj.onBeforeRender = this._onBeforeRender; obj.userData[USERDATA_OUTLINE_TARGET_INDEX] = targetIndex; } for (const child of obj.children) { - this._traverseAndSetupMaterials(child, maskMaterial, targetIndex); + this._traverseAndSetupMaterials(child, maskMaterials, targetIndex); } } @@ -549,7 +590,7 @@ export class SelectiveOutlinePass extends Pass { private _renderMask( renderer: WebGLRenderer, targets: TargetEntry[], - maskMaterial: ShaderMaterial, + maskMaterials: MaskMaterialVariants, renderTarget: WebGLRenderTarget, ): void { for (const { object3d, targetIndex } of targets) { @@ -558,7 +599,7 @@ export class SelectiveOutlinePass extends Pass { object3d.parent.remove(object3d); } this._outlineScene.add(object3d); - this._traverseAndSetupMaterials(object3d, maskMaterial, targetIndex); + this._traverseAndSetupMaterials(object3d, maskMaterials, targetIndex); } renderer.setRenderTarget(renderTarget); @@ -661,15 +702,14 @@ export class SelectiveOutlinePass extends Pass { // Render occluded mask (depth-tested against scene) if (this._occludedTargets.length > 0) { - this._maskMaterial.uniforms[UNIFORM_T_DEPTH].value = readBuffer.depthTexture; - this._maskMaterial.uniforms[UNIFORM_CAMERA_NEAR].value = this._camera.near; - this._maskMaterial.uniforms[UNIFORM_CAMERA_FAR].value = this._camera.far; - this._renderMask(renderer, this._occludedTargets, this._maskMaterial, this._occludedMaskRenderTarget); + this._setMaskMaterialUniforms(this._occludedMaskMaterials, readBuffer.depthTexture); + this._renderMask(renderer, this._occludedTargets, this._occludedMaskMaterials, this._occludedMaskRenderTarget); } // Render non-occluded mask (depth-tested against each other only) if (this._nonOccludedTargets.length > 0) { - this._renderMask(renderer, this._nonOccludedTargets, this._maskNoDepthMaterial, this._nonOccludedMaskRenderTarget); + this._setMaskMaterialUniforms(this._nonOccludedMaskMaterials, null); + this._renderMask(renderer, this._nonOccludedTargets, this._nonOccludedMaskMaterials, this._nonOccludedMaskRenderTarget); } // HACK: Detach writeBuffer's depth attachment during composite to avoid a WebGL @@ -703,8 +743,14 @@ export class SelectiveOutlinePass extends Pass { public dispose(): void { this._occludedMaskRenderTarget.dispose(); this._nonOccludedMaskRenderTarget.dispose(); - this._maskMaterial.dispose(); - this._maskNoDepthMaterial.dispose(); + this._occludedMaskMaterials.none.dispose(); + this._occludedMaskMaterials.map.dispose(); + this._occludedMaskMaterials.alphaMap.dispose(); + this._occludedMaskMaterials.mapAndAlphaMap.dispose(); + this._nonOccludedMaskMaterials.none.dispose(); + this._nonOccludedMaskMaterials.map.dispose(); + this._nonOccludedMaskMaterials.alphaMap.dispose(); + this._nonOccludedMaskMaterials.mapAndAlphaMap.dispose(); this._outlineMaterial.dispose(); this._fsQuad.dispose(); }