diff --git a/packages/dev/core/src/Meshes/instancedMesh.ts b/packages/dev/core/src/Meshes/instancedMesh.ts index 29eda86ebcf..be6ec94aa23 100644 --- a/packages/dev/core/src/Meshes/instancedMesh.ts +++ b/packages/dev/core/src/Meshes/instancedMesh.ts @@ -771,7 +771,7 @@ Mesh.prototype._processInstancedBuffers = function (visibleInstances: Nullable = null; + /** + * Force rendering the geometry through geometry buffer. + */ + @serialize() + private _forceGeometryBuffer: boolean = false; + private get _geometryBufferRenderer(): Nullable { + if (!this._forceGeometryBuffer) { + return null; + } + return this._forcedGeometryBuffer ?? this._scene.geometryBufferRenderer; + } + private get _prePassRenderer(): Nullable { + if (this._forceGeometryBuffer) { + return null; + } + return this._scene.prePassRenderer; + } + + /** + * Gets or sets the outline color (default: (1, 0.5, 0) - orange) + */ + @serializeAsColor3("outlineColor") + public get outlineColor(): Color3 { + return this._thinOutlinePostProcess.outlineColor; + } + public set outlineColor(value: Color3) { + this._thinOutlinePostProcess.outlineColor = value; + } + + /** + * Gets or sets the outline thickness (default: 2.0) + */ + @serialize("outlineThickness") + public get outlineThickness(): number { + return this._thinOutlinePostProcess.outlineThickness; + } + public set outlineThickness(value: number) { + this._thinOutlinePostProcess.outlineThickness = value; + } + + /** + * Gets or sets the occlusion strength (default: 0.8) + */ + @serialize("occlusionStrength") + public get occlusionStrength(): number { + return this._thinOutlinePostProcess.occlusionStrength; + } + public set occlusionStrength(value: number) { + this._thinOutlinePostProcess.occlusionStrength = value; + } + + private readonly _scene: Scene; + private _isDirty: boolean = true; + private _isDisposed: boolean = false; + private readonly _camerasToBeAttached: Camera[] = []; + private readonly _textureType: number; + + private readonly _thinOutlinePostProcess: ThinSelectionOutlinePostProcess; + private _maskRenderer: Nullable = null; + private _outlinePostProcess: Nullable = null; + + /** + * Constructs a new selection outline rendering pipeline + * @param name The rendering pipeline name + * @param scene The scene linked to this pipeline + * @param cameras The array of cameras that the rendering pipeline will be attached to + * @param forceGeometryBuffer Set to true if you want to use the legacy geometry buffer renderer. You can also pass an existing instance of GeometryBufferRenderer if you want to use your own geometry buffer renderer. + * @param textureType The type of texture where the scene will be rendered (default: Constants.TEXTURETYPE_UNSIGNED_BYTE) + * + */ + public constructor( + name: string, + scene: Scene, + cameras?: Camera[], + forceGeometryBuffer: boolean | GeometryBufferRenderer = false, + textureType = Constants.TEXTURETYPE_UNSIGNED_BYTE + ) { + super(scene.getEngine(), name); + + this._cameras = cameras || scene.cameras; + this._cameras = this._cameras.slice(); + this._camerasToBeAttached = this._cameras.slice(); + + this._scene = scene; + this._textureType = textureType; + if (forceGeometryBuffer instanceof GeometryBufferRenderer) { + this._forceGeometryBuffer = true; + this._forcedGeometryBuffer = forceGeometryBuffer; + } else { + this._forceGeometryBuffer = forceGeometryBuffer; + } + + this._thinOutlinePostProcess = new ThinSelectionOutlinePostProcess(this._name, scene.getEngine()); + + // Set up assets + if (this._forceGeometryBuffer) { + if (!this._forcedGeometryBuffer) { + scene.enableGeometryBufferRenderer(); + } + } else { + scene.enablePrePassRenderer(); + } + } + + /** + * Get the class name + * @returns "SelectionOutlineRenderingPipeline" + */ + public override getClassName(): string { + return "SelectionOutlineRenderingPipeline"; + } + + /** + * Adds a camera to the pipeline + * @param camera the camera to be added + */ + public addCamera(camera: Camera): void { + this._camerasToBeAttached.push(camera); + this._isDirty = true; + if (this._maskRenderer !== null) { + // if pipeline is already built, build again + this._buildPipeline(); + } + } + + /** + * Removes a camera from the pipeline + * @param camera the camera to remove + */ + public removeCamera(camera: Camera): void { + const index = this._camerasToBeAttached.indexOf(camera); + this._camerasToBeAttached.splice(index, 1); + this._isDirty = true; + if (this._maskRenderer !== null) { + // if pipeline is already built, build again + this._buildPipeline(); + } + } + + /** + * Removes the internal pipeline assets and detaches the pipeline from the scene cameras + * @param disableGeometryBufferRenderer Set to true if you want to disable the Geometry Buffer renderer + */ + public override dispose(disableGeometryBufferRenderer: boolean = false): void { + if (this._isDisposed) { + return; + } + + this._disposePipeline(); + + if (disableGeometryBufferRenderer && !this._forcedGeometryBuffer) { + this._scene.disableGeometryBufferRenderer(); + } + + this._thinOutlinePostProcess.dispose(); + + this._isDisposed = true; + + super.dispose(); + } + + private _getTextureSize(): ISize { + const engine = this._scene.getEngine(); + const prePassRenderer = this._prePassRenderer; + + let textureSize: ISize = { width: engine.getRenderWidth(), height: engine.getRenderHeight() }; + + if (prePassRenderer && this._scene.activeCamera?._getFirstPostProcess() === this._outlinePostProcess) { + const renderTarget = prePassRenderer.getRenderTarget(); + + if (renderTarget && renderTarget.textures) { + textureSize = renderTarget.textures[prePassRenderer.getIndex(Constants.PREPASS_COLOR_TEXTURE_TYPE)].getSize(); + } + } else if (this._outlinePostProcess!.inputTexture) { + textureSize.width = this._outlinePostProcess!.inputTexture.width; + textureSize.height = this._outlinePostProcess!.inputTexture.height; + } + + return textureSize; + } + + private _buildPipeline(): void { + if (!this._isDirty || this._isDisposed) { + return; + } + this._disposePipeline(); + this._isDirty = false; + + const scene = this._scene; + this._maskRenderer = new SelectionMaskRenderer(this._name, scene); + this._outlinePostProcess = this._createSelectionOutlinePostProcess(this._textureType); + this.addEffect( + new PostProcessRenderEffect( + scene.getEngine(), + this.SelectionOutlineEffect, + () => { + return this._outlinePostProcess; + }, + true + ) + ); + + scene.postProcessRenderPipelineManager.addPipeline(this); + scene.postProcessRenderPipelineManager.attachCamerasToRenderPipeline(this._name, this._cameras); + } + + private _disposePipeline(): void { + this._maskRenderer?.dispose(); + this._maskRenderer = null; + + if (this._outlinePostProcess) { + for (let i = 0; i < this._cameras.length; i++) { + const camera = this._cameras[i]; + this._outlinePostProcess.dispose(camera); + } + this._outlinePostProcess = null; + } + + this._scene.postProcessRenderPipelineManager.detachCamerasFromRenderPipeline(this._name, this._cameras); + // get back cameras to be used to reattach pipeline + this._cameras = this._camerasToBeAttached.slice(); + this._reset(); + + this._isDirty = true; // to allow rebuilding + } + + private _createSelectionOutlinePostProcess(textureType: number): PostProcess { + const outlinePostProcess = new PostProcess("selectionOutline", ThinSelectionOutlinePostProcess.FragmentUrl, { + engine: this._scene.getEngine(), + textureType, + effectWrapper: this._thinOutlinePostProcess, + }); + if (!this._forceGeometryBuffer) { + outlinePostProcess._prePassEffectConfiguration = new SelectionOutlineConfiguration(); + } + outlinePostProcess.samples = this._msaaSamples; + outlinePostProcess.onApplyObservable.add((effect: Effect) => { + if (this._geometryBufferRenderer) { + effect.setTexture("depthSampler", this._geometryBufferRenderer.getGBuffer().textures[0]); + } else if (this._prePassRenderer) { + effect.setTexture("depthSampler", this._prePassRenderer.getRenderTarget().textures[this._prePassRenderer.getIndex(Constants.PREPASS_DEPTH_TEXTURE_TYPE)]); + } + + const textureSize = this._getTextureSize(); + this._thinOutlinePostProcess.textureWidth = textureSize.width; + this._thinOutlinePostProcess.textureHeight = textureSize.height; + + effect.setTexture("maskSampler", this._maskRenderer!.getMaskTexture()); + }); + + return outlinePostProcess; + } + + /** + * Clears the current selection + * @param disablePipeline If true, disables the pipeline until a new selection is set (default: false) + */ + public clearSelection(disablePipeline: boolean = false): void { + if (this._maskRenderer === null) { + return; + } + + this._maskRenderer.clearSelection(); + if (disablePipeline) { + this._disposePipeline(); + } + } + + /** + * Adds meshe or group of meshes to the current selection + * + * If a group of meshes is provided, they will outline as a single unit + * @param meshes Meshes to add to the selection + */ + public addSelection(meshes: (AbstractMesh | AbstractMesh[])[]): void { + if (meshes.length === 0) { + return; + } + this._buildPipeline(); + this._maskRenderer!.addSelection(meshes); + } + + /** + * Sets the current selection, replacing any previous selection + * + * If a group of meshes is provided, they will outline as a single unit + * @param meshes Meshes to set as the current selection + * @param disablePipeline If true, disables the pipeline if the selection is empty (default: false) + */ + public setSelection(meshes: (AbstractMesh | AbstractMesh[])[], disablePipeline: boolean = false): void { + if (meshes.length === 0) { + if (disablePipeline) { + this._disposePipeline(); + } + return; + } + this._buildPipeline(); + this._maskRenderer!.setSelection(meshes); + } + + /** + * Gets the current selection + */ + public get selection(): readonly AbstractMesh[] { + if (this._maskRenderer === null) { + return []; + } + return this._maskRenderer.selection; + } + + /** + * Serialize the rendering pipeline (Used when exporting) + * @returns the serialized object + */ + public serialize(): any { + const serializationObject = SerializationHelper.Serialize(this); + serializationObject.customType = "SelectionOutlineRenderingPipeline"; + + return serializationObject; + } + + /** + * Parse the serialized pipeline + * @param source Source pipeline. + * @param scene The scene to load the pipeline to. + * @param rootUrl The URL of the serialized pipeline. + * @returns An instantiated pipeline from the serialized object. + */ + public static Parse(source: any, scene: Scene, rootUrl: string): SelectionOutlineRenderingPipeline { + return SerializationHelper.Parse( + () => new SelectionOutlineRenderingPipeline(source._name, scene, undefined, source._forceGeometryBuffer, source._textureType), + source, + scene, + rootUrl + ); + } +} + +RegisterClass("BABYLON.SelectionOutlineRenderingPipeline", SelectionOutlineRenderingPipeline); diff --git a/packages/dev/core/src/PostProcesses/index.ts b/packages/dev/core/src/PostProcesses/index.ts index aa5efcdf49a..9e70b7e263f 100644 --- a/packages/dev/core/src/PostProcesses/index.ts +++ b/packages/dev/core/src/PostProcesses/index.ts @@ -51,6 +51,7 @@ export * from "./thinMotionBlurPostProcess"; export * from "./thinPassPostProcess"; export * from "./thinSharpenPostProcess"; export * from "./thinScreenSpaceCurvaturePostProcess"; +export * from "./thinSelectionOutlinePostProcess"; export * from "./thinTonemapPostProcess"; // Postprocess diff --git a/packages/dev/core/src/PostProcesses/thinSelectionOutlinePostProcess.ts b/packages/dev/core/src/PostProcesses/thinSelectionOutlinePostProcess.ts new file mode 100644 index 00000000000..e6b5db8a391 --- /dev/null +++ b/packages/dev/core/src/PostProcesses/thinSelectionOutlinePostProcess.ts @@ -0,0 +1,90 @@ +import type { AbstractEngine } from "../Engines/abstractEngine"; +import { Engine } from "../Engines/engine"; +import type { EffectWrapperCreationOptions } from "../Materials/effectRenderer"; +import { EffectWrapper } from "../Materials/effectRenderer"; +import { Color3 } from "../Maths/math.color"; +import type { Nullable } from "../types"; + +/** + * Post process used to render a thin outline around selected objects + */ +export class ThinSelectionOutlinePostProcess extends EffectWrapper { + /** + * The fragment shader url + */ + public static readonly FragmentUrl = "selectionOutline"; + + /** + * The list of uniforms used by the effect + */ + public static readonly Uniforms = ["screenSize", "outlineColor", "outlineThickness", "occlusionStrength"]; + + /** + * The list of samplers used by the effect + */ + public static readonly Samplers = ["maskSampler", "depthSampler"]; + + protected override _gatherImports(useWebGPU: boolean, list: Promise[]): void { + if (useWebGPU) { + this._webGPUReady = true; + list.push(import("../ShadersWGSL/selectionOutline.fragment")); + } else { + list.push(import("../Shaders/selectionOutline.fragment")); + } + } + + /** + * Constructs a new thin selection outline post process + * @param name Name of the effect + * @param engine Engine to use to render the effect. If not provided, the last created engine will be used + * @param options Options to configure the effect + */ + public constructor(name: string, engine: Nullable = null, options?: EffectWrapperCreationOptions) { + super({ + ...options, + name, + engine: engine || Engine.LastCreatedEngine!, + useShaderStore: true, + useAsPostProcess: true, + fragmentShader: ThinSelectionOutlinePostProcess.FragmentUrl, + uniforms: ThinSelectionOutlinePostProcess.Uniforms, + samplers: ThinSelectionOutlinePostProcess.Samplers, + }); + } + + /** + * THe outline color + */ + public outlineColor: Color3 = new Color3(1, 0.5, 0); + + /** + * The thickness of the edges + */ + public outlineThickness: number = 2.0; + + /** + * The strength of the occlusion effect (default: 0.8) + */ + public occlusionStrength: number = 0.8; + + /** + * The width of the source texture + */ + public textureWidth: number = 0; + + /** + * The height of the source texture + */ + public textureHeight: number = 0; + + public override bind(noDefaultBindings = false): void { + super.bind(noDefaultBindings); + + const effect = this._drawWrapper.effect!; + + effect.setFloat2("screenSize", this.textureWidth, this.textureHeight); + effect.setColor3("outlineColor", this.outlineColor); + effect.setFloat("outlineThickness", this.outlineThickness); + effect.setFloat("occlusionStrength", this.occlusionStrength); + } +} diff --git a/packages/dev/core/src/Rendering/index.ts b/packages/dev/core/src/Rendering/index.ts index ff32ab94837..4e88374da91 100644 --- a/packages/dev/core/src/Rendering/index.ts +++ b/packages/dev/core/src/Rendering/index.ts @@ -13,6 +13,7 @@ export * from "./iblCdfGeneratorSceneComponent"; export * from "./IBLShadows/iblShadowsRenderPipeline"; export * from "./prePassRenderer"; export * from "./prePassRendererSceneComponent"; +export * from "./selectionMaskRenderer"; export * from "./subSurfaceSceneComponent"; export * from "./outlineRenderer"; export * from "./renderingGroup"; diff --git a/packages/dev/core/src/Rendering/selectionMaskRenderer.ts b/packages/dev/core/src/Rendering/selectionMaskRenderer.ts new file mode 100644 index 00000000000..61a9b08bd18 --- /dev/null +++ b/packages/dev/core/src/Rendering/selectionMaskRenderer.ts @@ -0,0 +1,333 @@ +import type { Camera } from "../Cameras/camera"; +import type { AbstractEngine } from "../Engines/abstractEngine"; +import { Constants } from "../Engines/constants"; +import type { Effect } from "../Materials/effect"; +import { ShaderLanguage } from "../Materials/shaderLanguage"; +import type { IShaderMaterialOptions } from "../Materials/shaderMaterial"; +import { ShaderMaterial } from "../Materials/shaderMaterial"; +import { RenderTargetTexture } from "../Materials/Textures/renderTargetTexture"; +import { Color4 } from "../Maths/math.color"; +import type { Matrix } from "../Maths/math.vector"; +import type { AbstractMesh } from "../Meshes/abstractMesh"; +import { VertexBuffer } from "../Meshes/buffer"; +import type { InstancedMesh } from "../Meshes/instancedMesh"; +import type { Mesh } from "../Meshes/mesh"; +import type { SubMesh } from "../Meshes/subMesh"; +import type { Observer } from "../Misc/observable"; +import type { Scene } from "../scene"; +import type { Nullable } from "../types"; + +/** + * Selection material used to generate the selection mask + * + * Selection material use r and g channels to store the selection ID and depth information + */ +class SelectionMaterial extends ShaderMaterial { + private readonly _meshUniqueIdToSelectionId: number[]; + + /** + * Constructs a new selection mask material + * @param name The name of the material + * @param scene The scene the material belongs to + * @param shaderLanguage The shader language to use + * @param meshUniqueIdToSelectionId Mapping from mesh unique IDs to selection IDs + */ + public constructor(name: string, scene: Scene, shaderLanguage: ShaderLanguage, meshUniqueIdToSelectionId: number[]) { + const defines: string[] = []; + const options: Partial = { + attributes: [VertexBuffer.PositionKind, SelectionMaskRenderer.InstanceSelectionIdAttributeName], + uniforms: ["world", "viewProjection", "view", "selectionId"], + needAlphaBlending: false, + defines: defines, + useClipPlane: null, + shaderLanguage: shaderLanguage, + extraInitializationsAsync: async () => { + if (this.shaderLanguage === ShaderLanguage.WGSL) { + await Promise.all([import("../ShadersWGSL/selection.fragment"), import("../ShadersWGSL/selection.vertex")]); + } else { + await Promise.all([import("../Shaders/selection.fragment"), import("../Shaders/selection.vertex")]); + } + }, + }; + super(name, scene, "selection", options, false); + + this._meshUniqueIdToSelectionId = meshUniqueIdToSelectionId; + } + + /** + * Binds the material to the mesh + * @param world The world matrix + * @param mesh The mesh to bind the material to + * @param effectOverride An optional effect override + * @param subMesh The submesh to bind the material to + */ + public override bind(world: Matrix, mesh?: AbstractMesh, effectOverride?: Nullable, subMesh?: SubMesh): void { + super.bind(world, mesh, effectOverride, subMesh); + if (!mesh) { + return; + } + + const storeEffectOnSubMeshes = subMesh && this._storeEffectOnSubMeshes; + const effect = effectOverride ?? (storeEffectOnSubMeshes ? subMesh.effect : this.getEffect()); + + if (!effect) { + return; + } + + if (!mesh.hasInstances && !mesh.isAnInstance && this._meshUniqueIdToSelectionId[mesh.uniqueId] !== undefined) { + const selectionId = this._meshUniqueIdToSelectionId[mesh.uniqueId]; + effect.setFloat("selectionId", selectionId); + } + } +} + +/** + * Selection mask renderer + * + * Renders selected objects to a mask texture where r and g channels store selection ID and depth information + */ +export class SelectionMaskRenderer { + /** + * Name of the instance selection ID attribute + * @internal + */ + public static readonly InstanceSelectionIdAttributeName = "instanceSelectionId"; + + private readonly _name: string; + private readonly _scene: Scene; + private _maskTexture: Nullable; + private _isRttAddedToScene; + + private _resizeObserver: Nullable>; + + private readonly _meshUniqueIdToSelectionId: number[] = []; + + private readonly _selectionMaterialCache: Nullable[] = new Array(9).fill(null); + private readonly _selection: AbstractMesh[] = []; + + private _nextSelectionId = 1; + + /** Shader language used by the generator */ + protected _shaderLanguage = ShaderLanguage.GLSL; + + /** + * Gets the shader language used in this generator. + */ + public get shaderLanguage(): ShaderLanguage { + return this._shaderLanguage; + } + + /** + * Constructs a new selection mask renderer + * @param name Name of the render target + * @param scene The scene the renderer belongs to + * @param camera The camera to be used to render the depth map (default: scene's active camera) + */ + public constructor(name: string, scene: Scene, camera: Nullable = null) { + this._name = name; + this._scene = scene; + + const engine = scene.getEngine(); + + if (engine.isWebGPU) { + this._shaderLanguage = ShaderLanguage.WGSL; + } + + this._maskTexture = new RenderTargetTexture(name, { width: engine.getRenderWidth(), height: engine.getRenderHeight() }, this._scene, { + type: Constants.TEXTURETYPE_HALF_FLOAT, + format: Constants.TEXTUREFORMAT_RG, + }); + this._maskTexture.wrapU = Constants.TEXTURE_CLAMP_ADDRESSMODE; + this._maskTexture.wrapV = Constants.TEXTURE_CLAMP_ADDRESSMODE; + this._maskTexture.refreshRate = 1; + this._maskTexture.renderParticles = false; + this._maskTexture.renderList = null; + this._maskTexture.noPrePassRenderer = true; + this._maskTexture.clearColor = new Color4(0, 0, 1, 1); + + this._maskTexture.activeCamera = camera; + this._maskTexture.ignoreCameraViewport = true; + this._maskTexture.useCameraPostProcesses = false; + + this._isRttAddedToScene = false; + // this._scene.customRenderTargets.push(this._maskTexture); + + this._resizeObserver = engine.onResizeObservable.add(() => { + this._maskTexture?.resize({ width: engine.getRenderWidth(), height: engine.getRenderHeight() }); + }); + + this._addRenderTargetToScene(); + } + + /** + * Disposes the selection mask renderer + */ + public dispose(): void { + this.clearSelection(); + + if (this._maskTexture !== null) { + this._removeRenderTargetFromScene(); + this._maskTexture.dispose(); + this._maskTexture = null; + } + this._clearSelectionMaterials(); + + this._resizeObserver?.remove(); + this._resizeObserver = null; + } + + private _clearSelectionMaterials(): void { + for (let i = 0; i < this._selectionMaterialCache.length; ++i) { + const material = this._selectionMaterialCache[i]; + if (material !== null) { + material.dispose(); + this._selectionMaterialCache[i] = null; + } + } + } + + private _getSelectionMaterial(scene: Scene, fillMode: number): SelectionMaterial { + if (fillMode < 0 || 8 < fillMode) { + fillMode = Constants.MATERIAL_TriangleFillMode; + } + + const cachedMaterial = this._selectionMaterialCache[fillMode]; + if (cachedMaterial) { + return cachedMaterial; + } + + const engine = scene.getEngine(); + + if (engine.isWebGPU) { + this._shaderLanguage = ShaderLanguage.WGSL; + } + + const newMaterial = new SelectionMaterial(this._name + "_selection_material", scene, this._shaderLanguage, this._meshUniqueIdToSelectionId); + newMaterial.fillMode = fillMode; + newMaterial.backFaceCulling = false; + + this._selectionMaterialCache[fillMode] = newMaterial; + return newMaterial; + } + + private _addRenderTargetToScene(): void { + if (this._isRttAddedToScene) { + return; + } + this._scene.customRenderTargets.push(this._maskTexture!); + this._isRttAddedToScene = true; + } + + private _removeRenderTargetFromScene(): void { + if (!this._isRttAddedToScene) { + return; + } + const index = this._scene.customRenderTargets.indexOf(this._maskTexture!); + if (index !== -1) { + this._scene.customRenderTargets.splice(index, 1); + } + this._isRttAddedToScene = false; + } + + /** + * Clears the current selection + */ + public clearSelection(): void { + if (this._selection.length === 0) { + return; + } + + for (let index = 0; index < this._selection.length; ++index) { + const mesh = this._selection[index]; + if (mesh.hasInstances) { + (mesh as Mesh).removeVerticesData(SelectionMaskRenderer.InstanceSelectionIdAttributeName); + } + if (this._maskTexture) { + this._maskTexture.setMaterialForRendering(mesh, undefined); + } + } + this._selection.length = 0; + this._meshUniqueIdToSelectionId.length = 0; + if (this._maskTexture) { + this._maskTexture.renderList = []; + } + + this._nextSelectionId = 1; + } + + /** + * Adds meshe or group of meshes to the current selection + * + * If a group of meshes is provided, they will outline as a single unit + * @param meshes Meshes to add to the selection + */ + public addSelection(meshes: (AbstractMesh | AbstractMesh[])[]): void { + if (meshes.length === 0) { + return; + } + + // prepare target texture + const maskTexture = this._maskTexture; + if (!maskTexture) { + return; // if disposed + } + + let nextId = this._nextSelectionId; + for (let groupIndex = 0; groupIndex < meshes.length; ++groupIndex) { + const meshOrGroup = meshes[groupIndex]; + const id = nextId; + nextId += 1; + + const group = Array.isArray(meshOrGroup) ? meshOrGroup : [meshOrGroup]; + for (let meshIndex = 0; meshIndex < group.length; ++meshIndex) { + const mesh = group[meshIndex]; + + const material = this._getSelectionMaterial(this._scene, mesh.material?.fillMode ?? Constants.MATERIAL_TriangleFillMode); + maskTexture.setMaterialForRendering(group, material); + this._selection.push(mesh); // add to render list + + if (mesh.hasInstances || mesh.isAnInstance) { + const sourceMesh = (mesh as InstancedMesh).sourceMesh ?? (mesh as Mesh); + + if (sourceMesh.instancedBuffers?.[SelectionMaskRenderer.InstanceSelectionIdAttributeName] === undefined) { + sourceMesh.registerInstancedBuffer(SelectionMaskRenderer.InstanceSelectionIdAttributeName, 1); + // todo: consider unregistering buffer on dispose + } + + mesh.instancedBuffers[SelectionMaskRenderer.InstanceSelectionIdAttributeName] = id; + } else { + this._meshUniqueIdToSelectionId[mesh.uniqueId] = id; + } + } + } + this._nextSelectionId = nextId; + + maskTexture.renderList = [...this._selection]; + } + + /** + * Sets the current selection, replacing any previous selection + * + * If a group of meshes is provided, they will outline as a single unit + * @param meshes Meshes to set as the current selection + */ + public setSelection(meshes: (AbstractMesh | AbstractMesh[])[]): void { + this.clearSelection(); + this.addSelection(meshes); + } + + /** + * Gets the mask texture. if renderer has been disposed, null is returned + * @returns The mask texture + */ + public getMaskTexture(): Nullable { + return this._maskTexture; + } + + /** + * Gets the current selection + */ + public get selection(): readonly AbstractMesh[] { + return this._selection; + } +} diff --git a/packages/dev/core/src/Rendering/selectionOutlineConfiguration.ts b/packages/dev/core/src/Rendering/selectionOutlineConfiguration.ts new file mode 100644 index 00000000000..4032bf2c2f1 --- /dev/null +++ b/packages/dev/core/src/Rendering/selectionOutlineConfiguration.ts @@ -0,0 +1,23 @@ +import { Constants } from "../Engines/constants"; +import type { PrePassEffectConfiguration } from "./prePassEffectConfiguration"; + +/** + * Contains all parameters needed for the prepass to perform + * selection outline rendering + */ +export class SelectionOutlineConfiguration implements PrePassEffectConfiguration { + /** + * Is selection outline enabled + */ + public enabled = false; + + /** + * Name of the configuration + */ + public name = "selectionOutline"; + + /** + * Textures that should be present in the MRT for this effect to work + */ + public readonly texturesRequired: number[] = [Constants.PREPASS_DEPTH_TEXTURE_TYPE]; +} diff --git a/packages/dev/core/src/Shaders/selection.fragment.fx b/packages/dev/core/src/Shaders/selection.fragment.fx new file mode 100644 index 00000000000..9128aeea5fb --- /dev/null +++ b/packages/dev/core/src/Shaders/selection.fragment.fx @@ -0,0 +1,23 @@ +#if defined(INSTANCES) +flat varying float vSelectionId; +#else +uniform float selectionId; +#endif +varying float vViewPosZ; + +#define CUSTOM_FRAGMENT_DEFINITIONS + +void main(void) { + +#define CUSTOM_FRAGMENT_MAIN_BEGIN + +#if defined(INSTANCES) + float id = vSelectionId; +#else + float id = selectionId; +#endif + + gl_FragColor = vec4(id, vViewPosZ, 0.0, 1.0); + +#define CUSTOM_FRAGMENT_MAIN_END +} diff --git a/packages/dev/core/src/Shaders/selection.vertex.fx b/packages/dev/core/src/Shaders/selection.vertex.fx new file mode 100644 index 00000000000..6189501f874 --- /dev/null +++ b/packages/dev/core/src/Shaders/selection.vertex.fx @@ -0,0 +1,46 @@ +// Attributes +attribute vec3 position; +#if defined(INSTANCES) +attribute float instanceSelectionId; +#endif + +#include +#include +#include +#include[0..maxSimultaneousMorphTargets] + +// Uniforms + +#include +uniform mat4 viewProjection; +uniform mat4 view; + +// Output +#if defined(INSTANCES) +flat varying float vSelectionId; +#endif +varying float vViewPosZ; + +#define CUSTOM_VERTEX_DEFINITIONS + +void main(void) { + +#define CUSTOM_VERTEX_MAIN_BEGIN + + vec3 positionUpdated = position; +#include +#include[0..maxSimultaneousMorphTargets] +#include +#include +#include + vec4 worldPos = finalWorld * vec4(positionUpdated, 1.0); + gl_Position = viewProjection * worldPos; + + vViewPosZ = (view * worldPos).z; + +#if defined(INSTANCES) + vSelectionId = instanceSelectionId; +#endif + +#define CUSTOM_VERTEX_MAIN_END +} diff --git a/packages/dev/core/src/Shaders/selectionOutline.fragment.fx b/packages/dev/core/src/Shaders/selectionOutline.fragment.fx new file mode 100644 index 00000000000..c4ef9e69da9 --- /dev/null +++ b/packages/dev/core/src/Shaders/selectionOutline.fragment.fx @@ -0,0 +1,62 @@ +// samplers +uniform sampler2D textureSampler; +uniform sampler2D maskSampler; +uniform sampler2D depthSampler; + +// varyings +varying vec2 vUV; + +// uniforms +uniform vec2 screenSize; +uniform vec3 outlineColor; +uniform float outlineThickness; +uniform float occlusionStrength; + +#define CUSTOM_FRAGMENT_DEFINITIONS + +void main(void) { + +#define CUSTOM_FRAGMENT_MAIN_BEGIN + + vec4 screenColor = texture2D(textureSampler, vUV); + + vec2 texelSize = 1.0 / screenSize; + vec2 sampleOffset = texelSize * outlineThickness; + + // sample mask texture for edge detection and depth-based occlusion + vec2 centerMask = texture2D(maskSampler, vUV).rg; + vec2 maskX = texture2D(maskSampler, vUV + vec2(sampleOffset.x, 0.0)).rg; + vec2 maskY = texture2D(maskSampler, vUV + vec2(0.0, sampleOffset.y)).rg; + vec2 maskXY = texture2D(maskSampler, vUV + sampleOffset).rg; + + // gradient magnitude edge detection + vec3 gradient = vec3( + centerMask.r - maskX.r, + centerMask.r - maskY.r, + centerMask.r - maskXY.r + ); + float edgeStrength = length(gradient); + float outlineMask = step(0.1, edgeStrength); // 0.1 is the outline threshold + + // sample depth texture for depth-based occlusion + float depthCenter = texture2D(depthSampler, vUV).r; + float depthX = texture2D(depthSampler, vUV + vec2(sampleOffset.x, 0.0)).r; + float depthY = texture2D(depthSampler, vUV + vec2(0.0, sampleOffset.y)).r; + float depthXY = texture2D(depthSampler, vUV + sampleOffset).r; + + const float occlusionThreshold = 1.0000001; + float occlusionCenter = step(occlusionThreshold, abs(centerMask.g - depthCenter)); + float occlusionX = step(occlusionThreshold, abs(maskX.g - depthX)); + float occlusionY = step(occlusionThreshold, abs(maskY.g - depthY)); + float occlusionXY = step(occlusionThreshold, abs(maskXY.g - depthXY)); + + float occlusionFactor = min(min(occlusionCenter, occlusionX), min(occlusionY, occlusionXY)); + + float finalOutlineMask = outlineMask * (1.0 - occlusionStrength * occlusionFactor); + + vec3 finalColor = mix(screenColor.rgb, outlineColor, finalOutlineMask); + + gl_FragColor = vec4(finalColor, screenColor.a); + +#define CUSTOM_FRAGMENT_MAIN_END +} diff --git a/packages/dev/core/src/ShadersWGSL/selection.fragment.fx b/packages/dev/core/src/ShadersWGSL/selection.fragment.fx new file mode 100644 index 00000000000..c651b7dbed8 --- /dev/null +++ b/packages/dev/core/src/ShadersWGSL/selection.fragment.fx @@ -0,0 +1,24 @@ +#if defined(INSTANCES) +flat varying vSelectionId: f32; +#else +uniform selectionId: f32; +#endif +varying vViewPosZ: f32; + +#define CUSTOM_FRAGMENT_DEFINITIONS + +@fragment +fn main(input: FragmentInputs) -> FragmentOutputs { + +#define CUSTOM_FRAGMENT_MAIN_BEGIN + +#if defined(INSTANCES) + var id: f32 = input.vSelectionId; +#else + var id: f32 = uniforms.selectionId; +#endif + + fragmentOutputs.color = vec4(id, input.vViewPosZ, 0.0, 1.0); + +#define CUSTOM_FRAGMENT_MAIN_END +} diff --git a/packages/dev/core/src/ShadersWGSL/selection.vertex.fx b/packages/dev/core/src/ShadersWGSL/selection.vertex.fx new file mode 100644 index 00000000000..e4ea12662bf --- /dev/null +++ b/packages/dev/core/src/ShadersWGSL/selection.vertex.fx @@ -0,0 +1,47 @@ +// Attributes +attribute position: vec3f; +#if defined(INSTANCES) +attribute instanceSelectionId: f32; +#endif + +#include +#include +#include +#include[0..maxSimultaneousMorphTargets] + +// Uniforms + +#include +uniform viewProjection: mat4x4f; +uniform view: mat4x4f; + +// Output +#if defined(INSTANCES) +flat varying vSelectionId: f32; +#endif +varying vViewPosZ: f32; + +#define CUSTOM_VERTEX_DEFINITIONS + +@vertex +fn main(input: VertexInputs) -> FragmentInputs { + +#define CUSTOM_VERTEX_MAIN_BEGIN + + var positionUpdated: vec3f = input.position; +#include +#include[0..maxSimultaneousMorphTargets] +#include +#include +#include + var worldPos: vec4f = finalWorld * vec4f(positionUpdated, 1.0); + vertexOutputs.position = uniforms.viewProjection * worldPos; + + vertexOutputs.vViewPosZ = (uniforms.view * worldPos).z; + +#if defined(INSTANCES) + vertexOutputs.vSelectionId = input.instanceSelectionId; +#endif + +#define CUSTOM_VERTEX_MAIN_END +} diff --git a/packages/dev/core/src/ShadersWGSL/selectionOutline.fragment.fx b/packages/dev/core/src/ShadersWGSL/selectionOutline.fragment.fx new file mode 100644 index 00000000000..7b39d9c0156 --- /dev/null +++ b/packages/dev/core/src/ShadersWGSL/selectionOutline.fragment.fx @@ -0,0 +1,66 @@ +// samplers +var textureSamplerSampler: sampler; +uniform textureSampler: texture_2d; +var maskSamplerSampler: sampler; +uniform maskSampler: texture_2d; +var depthSamplerSampler: sampler; +uniform depthSampler: texture_2d; + +// varyings +varying vUV: vec2f; + +// uniforms +uniform screenSize: vec2f; +uniform outlineColor: vec3f; +uniform outlineThickness: f32; +uniform occlusionStrength: f32; + +#define CUSTOM_FRAGMENT_DEFINITIONS + +@fragment +fn main(input: FragmentInputs) -> FragmentOutputs { + +#define CUSTOM_FRAGMENT_MAIN_BEGIN + + var screenColor: vec4f = textureSampleLevel(textureSampler, textureSamplerSampler, input.vUV, 0.0); + + var texelSize: vec2f = 1.0 / uniforms.screenSize; + var sampleOffset: vec2f = texelSize * uniforms.outlineThickness; + + // sample mask texture for edge detection and depth-based occlusion + var centerMask: vec2f = textureSampleLevel(maskSampler, maskSamplerSampler, input.vUV, 0.0).rg; + var maskX: vec2f = textureSampleLevel(maskSampler, maskSamplerSampler, input.vUV + vec2f(sampleOffset.x, 0.0), 0.0).rg; + var maskY: vec2f = textureSampleLevel(maskSampler, maskSamplerSampler, input.vUV + vec2f(0.0, sampleOffset.y), 0.0).rg; + var maskXY: vec2f = textureSampleLevel(maskSampler, maskSamplerSampler, input.vUV + sampleOffset, 0.0).rg; + + // gradient magnitude edge detection + var gradient: vec3f = vec3f( + centerMask.r - maskX.r, + centerMask.r - maskY.r, + centerMask.r - maskXY.r + ); + var edgeStrength: f32 = length(gradient); + var outlineMask: f32 = step(0.1, edgeStrength); // 0.1 is the outline threshold + + // sample depth texture for depth-based occlusion + var depthCenter: f32 = textureSampleLevel(depthSampler, depthSamplerSampler, input.vUV, 0.0).r; + var depthX: f32 = textureSampleLevel(depthSampler, depthSamplerSampler, input.vUV + vec2f(sampleOffset.x, 0.0), 0.0).r; + var depthY: f32 = textureSampleLevel(depthSampler, depthSamplerSampler, input.vUV + vec2f(0.0, sampleOffset.y), 0.0).r; + var depthXY: f32 = textureSampleLevel(depthSampler, depthSamplerSampler, input.vUV + sampleOffset, 0.0).r; + + const occlusionThreshold: f32 = 1.0000001; + var occlusionCenter: f32 = step(occlusionThreshold, abs(centerMask.g - depthCenter)); + var occlusionX: f32 = step(occlusionThreshold, abs(maskX.g - depthX)); + var occlusionY: f32 = step(occlusionThreshold, abs(maskY.g - depthY)); + var occlusionXY: f32 = step(occlusionThreshold, abs(maskXY.g - depthXY)); + + var occlusionFactor: f32 = min(min(occlusionCenter, occlusionX), min(occlusionY, occlusionXY)); + + var finalOutlineMask: f32 = outlineMask * (1.0 - uniforms.occlusionStrength * occlusionFactor); + + var finalColor: vec3f = mix(screenColor.rgb, uniforms.outlineColor, finalOutlineMask); + + fragmentOutputs.color = vec4f(finalColor, screenColor.a); + +#define CUSTOM_FRAGMENT_MAIN_END +}