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(); }