threepipe
Version:
A modern 3D viewer framework built on top of three.js, written in TypeScript, designed to make creating high-quality, modular, and extensible 3D experiences on the web simple and enjoyable.
561 lines (501 loc) • 21.6 kB
text/typescript
import {
LinearSRGBColorSpace,
MathUtils,
Matrix4,
Texture,
TextureDataType,
UnsignedByteType,
Vector2,
Vector4,
WebGLRenderTarget,
} from 'three'
import {ExtendedShaderPass, IPassID, IPipelinePass} from '../../postprocessing'
import {ThreeViewer} from '../../viewer'
import {PipelinePassPlugin} from '../base/PipelinePassPlugin'
import {uiConfig, uiFolderContainer, uiImage, uiSlider, uiToggle} from 'uiconfig.js'
import {
ICamera,
IMaterial,
IRenderManager,
IScene,
IWebGLRenderer,
PerspectiveCamera2,
PhysicalMaterial,
} from '../../core'
import {getOrCall, glsl, onChange2, serialize, updateBit, ValOrFunc} from 'ts-browser-helpers'
import {MaterialExtension} from '../../materials'
import {shaderReplaceString, shaderUtils} from '../../utils'
import {getTexelDecoding, matDefine, matDefineBool, uniform} from '../../three'
import ssaoPass from './shaders/SSAOPlugin.pass.glsl'
import ssaoPatch from './shaders/SSAOPlugin.patch.glsl'
import {uiConfigMaterialExtension} from '../../materials/MaterialExtender'
import {GBufferPlugin} from './GBufferPlugin'
import {GBufferUpdaterContext} from './GBufferMaterial'
export type SSAOPluginTarget = WebGLRenderTarget
/**
* SSAO Packing modes for different texture formats and use cases
*
* - **Mode 1**: `(r: ssao, gba: depth)` - SSAO in red channel, depth in green/blue/alpha
* - **Mode 2**: `(rgb: ssao, a: 1)` - SSAO in RGB channels, alpha set to 1
* - **Mode 3**: `(rgba: packed_ssao)` - Packed SSAO data across all RGBA channels
* - **Mode 4**: `(rgb: packed_ssao, a: 1)` - Packed SSAO in RGB channels, alpha set to 1
*
* @remarks
* Currently only modes 1 and 2 are fully supported in the shader implementation.
* Modes 3 and 4 are available for future use but may require additional shader updates.
*/
export type SSAOPacking = 1 | 2 | 3 | 4
/**
* Screen Space Ambient Occlusion (SSAO) Plugin for enhanced lighting and depth perception in 3D scenes.
*
* SSAO is a real-time ambient occlusion technique that approximates the soft shadows that occur in creases,
* holes, and surfaces that are close to each other. This plugin adds a pre-render pass that calculates
* ambient occlusion data which is then used by materials during the main render pass.
*
* ## Key Features
* - **Real-time SSAO calculation** using screen-space techniques
* - **Multiple packing modes** for different texture formats and optimization needs
* - **Per-material control** - materials can disable SSAO individually via userData
* - **Configurable quality settings** including sample count, radius, bias, and intensity
* - **Automatic serialization** of all settings with viewer configuration
* - **GBuffer integration** for optimized depth and normal data access
*
* ## Dependencies
* This plugin automatically adds {@link GBufferPlugin} as a dependency for efficient depth and normal data.
*
* ## Usage Scenarios
* - **Architectural visualization** - enhances depth perception in interior scenes
* - **Product visualization** - adds realistic ambient shadows to showcase products
* - **Game environments** - provides cost-effective ambient occlusion for real-time rendering
* - **CAD visualization** - improves understanding of complex mechanical assemblies
*
* ## Performance Considerations
* - Use lower `sizeMultiplier` values (0.5-0.75) for better performance on mobile devices
* - Combine with {@link ProgressivePlugin} and `TemporalAAPlugin` for temporal accumulation
* - Consider disabling SSAO on transparent or unlit materials to save processing
*
* @example Basic Usage
* ```typescript
* import {ThreeViewer, SSAOPlugin} from 'threepipe'
*
* const viewer = new ThreeViewer({
* plugins: [new SSAOPlugin()]
* })
*
* // Access the plugin and configure settings
* const ssaoPlugin = viewer.getPlugin(SSAOPlugin)!
* ssaoPlugin.pass.intensity = 1.2
* ssaoPlugin.pass.radius = 0.5
* ```
*
* @example Per-Material Control
* ```typescript
* // Disable SSAO for a specific material
* material.userData.ssaoDisabled = true
*
* // Disable SSAO casting (material won't contribute to AO calculation)
* material.userData.ssaoCastDisabled = true
* ```
*
* @example High Performance Setup
* ```typescript
* const ssaoPlugin = new SSAOPlugin(
* UnsignedByteType, // Buffer type
* 0.5, // Size multiplier for better performance
* true, // Enabled
* 1 // Packing mode
* )
* viewer.addPlugin(ssaoPlugin)
* ```
*
* @category Plugins
*/
export class SSAOPlugin
extends PipelinePassPlugin<SSAOPluginPass, 'ssao'> {
readonly passId = 'ssao'
public static readonly PluginType = 'SSAOPlugin'
public static readonly OldPluginType = 'SSAO'
/**
* Plugin dependencies - automatically adds GBufferPlugin for depth and normal data
* @internal
*/
dependencies = [GBufferPlugin]
/** The render target containing SSAO data */
target?: SSAOPluginTarget
/** Debug texture preview of the SSAO buffer (read-only) */
texture?: Texture
declare protected _pass?: SSAOPluginPass
/**
* Buffer data type for the SSAO render target.
* Cannot be changed after plugin creation.
*
* @remarks
* - `UnsignedByteType` - Standard 8-bit precision, good performance
* - `HalfFloatType` - 16-bit precision, better quality but slower
* - `FloatType` - 32-bit precision, highest quality but slowest
*/
readonly bufferType: TextureDataType
/**
* Render target size multiplier relative to the main canvas size.
* Cannot be changed after plugin creation.
*
* @remarks
* - `1.0` - Full resolution (highest quality)
* - `0.75` - 75% resolution (good balance)
* - `0.5` - Half resolution (better performance)
* - `0.25` - Quarter resolution (mobile performance)
*/
readonly sizeMultiplier: number
/**
* SSAO data packing mode for the render target.
* Cannot be changed after plugin creation.
*
* @see {@link SSAOPacking} for available packing modes
*/
readonly packing: SSAOPacking
/**
* Creates a new SSAOPlugin instance.
*
* @param bufferType - Data type for the SSAO buffer (default: UnsignedByteType)
* @param sizeMultiplier - Size multiplier for the render target (default: 1.0)
* @param enabled - Whether the plugin is initially enabled (default: true)
* @param packing - SSAO data packing mode (default: 1)
*/
constructor(
bufferType: TextureDataType = UnsignedByteType,
sizeMultiplier = 1,
enabled = true,
packing: SSAOPacking = 1,
) {
super()
this.enabled = enabled
this.bufferType = bufferType
this.sizeMultiplier = sizeMultiplier
this.packing = packing
}
protected _createTarget(recreate = true) {
if (!this._viewer) return
if (recreate) this._disposeTarget()
if (!this.target)
this.target = this._viewer.renderManager.createTarget<SSAOPluginTarget>(
{
depthBuffer: false,
type: this.bufferType,
sizeMultiplier: this.sizeMultiplier,
// magFilter: NearestFilter,
// minFilter: NearestFilter,
// generateMipmaps: false,
// encoding: LinearEncoding,
colorSpace: LinearSRGBColorSpace,
})
this.texture = this.target.texture
this.texture.name = 'ssaoBuffer'
// if (this._pass) this._pass.target = this.target
}
protected _disposeTarget() {
if (!this._viewer) return
if (this.target) {
this._viewer.renderManager.disposeTarget(this.target)
this.target = undefined
}
this.texture = undefined
}
private _gbufferUnpackExtension = undefined as MaterialExtension|undefined
private _gbufferUnpackExtensionChanged = ()=>{
if (!this._pass || !this._viewer) throw new Error('SSAOPlugin: pass/viewer not created yet')
const newExtension = this._viewer.renderManager.gbufferUnpackExtension
if (this._gbufferUnpackExtension === newExtension) return
if (this._gbufferUnpackExtension) this._pass.material.unregisterMaterialExtensions([this._gbufferUnpackExtension])
this._gbufferUnpackExtension = newExtension
if (this._gbufferUnpackExtension) this._pass.material.registerMaterialExtensions([this._gbufferUnpackExtension])
else this._viewer.console.warn('SSAOPlugin: GBuffer unpack extension removed')
}
protected _createPass() {
if (!this._viewer) throw new Error('SSAOPlugin: viewer not set')
if (!this._viewer.renderManager.gbufferTarget || !this._viewer.renderManager.gbufferUnpackExtension)
throw new Error('SSAOPlugin: GBuffer target not created. GBufferPlugin or DepthBufferPlugin is required.')
this._createTarget(true)
return new SSAOPluginPass(this.passId, ()=>this.target, this.packing)
}
onAdded(viewer: ThreeViewer) {
super.onAdded(viewer)
viewer.forPlugin(GBufferPlugin, (gbuffer) => {
gbuffer.registerGBufferUpdater(this.constructor.PluginType, this.updateGBufferFlags.bind(this))
}, (gbuffer)=>{
gbuffer.unregisterGBufferUpdater(this.constructor.PluginType)
}, this)
this._gbufferUnpackExtensionChanged()
viewer.renderManager.addEventListener('gbufferUnpackExtensionChanged', this._gbufferUnpackExtensionChanged)
}
onRemove(viewer: ThreeViewer): void {
this._disposeTarget()
return super.onRemove(viewer)
}
fromJSON(data: any, meta?: any): this|null|Promise<this|null> {
// legacy
if (data.passes?.ssao) {
data = {...data}
data.pass = data.passes.ssao
delete data.passes
if (data.pass.enabled !== undefined) data.enabled = data.pass.enabled
}
return super.fromJSON(data, meta)
}
updateGBufferFlags(data: Vector4, c: GBufferUpdaterContext): void {
if (!c.material || !c.material.userData) return
const disabled = c.material.userData.ssaoCastDisabled || c.material.userData.pluginsDisabled
const x = disabled ? 0 : 1
data.w = updateBit(data.w, 3, x)
if (disabled && this._pass) this._pass.checkGBufferFlag = true
}
/**
* @deprecated use {@link target} instead
*/
get aoTarget() {
console.warn('SSAOPlugin: aoTarget is deprecated, use target instead')
return this.target
}
}
export class SSAOPluginPass extends ExtendedShaderPass implements IPipelinePass {
before = ['render']
after = ['gbuffer', 'depth']
required = ['render'] // gbuffer required check done in plugin.
// todo bilateralPass
// @serialize() readonly bilateralPass: BilateralFilterPass
// todo old deserialize
// @serialize() readonly parameters: SSAOParams = {
// intensity: 0.25,
// occlusionWorldRadius: 1,
// bias: 0.001,
// falloff: 1.3,
// }
intensity = 0.25
occlusionWorldRadius = 1
/**
* Whether to automatically adapt the occlusion radius based on the scene size.
* This is useful when scene is not centered or normalized
*/
autoRadius = false
bias = 0.001
falloff = 1.3
numSamples = 8
/**
* Whether to check for gbuffer flag or not. This is used to disable SSAO casting by some objects. its enabled automatically by the SSAOPlugin when required.
* This is disabled by default so that we dont read texture for no reason.
*/
checkGBufferFlag = false
// todo after bilateralPass is implemented
// @bindToValue({obj: 'bilateralPass', key: 'enabled', onChange: 'setDirty'})
// smoothEnabled = true
// todo after bilateralPass is implemented
// @bindToValue({obj: 'bilateralPass', key: 'enabled', onChange: 'setDirty'})
// smoothEdgeSharpness = true
split = 0
constructor(public readonly passId: IPassID, public target?: ValOrFunc<WebGLRenderTarget|undefined>, packing: SSAOPacking = 1) {
super({
defines: {
['LINEAR_DEPTH']: 1, // todo set from unpack extension
['NUM_SAMPLES']: 11,
['NUM_SPIRAL_TURNS']: 3,
['SSAO_PACKING']: packing, // 1 is (r: ssao, gba: depth), 2 is (rgb: ssao, a: 1), 3 is (rgba: packed_ssao), 4 is (rgb: packed ssao, a: 1)
['PERSPECTIVE_CAMERA']: 1, // set in PerspectiveCamera2
['CHECK_GBUFFER_FLAG']: 0,
},
uniforms: {
// tLastThis: {value: null},
screenSize: {value: new Vector2(0, 0)}, // set in ExtendedRenderMaterial
saoData: {value: new Vector4()},
frameCount: {value: 0}, // set in RenderManager
cameraNearFar: {value: new Vector2(0.1, 1000)}, // set in PerspectiveCamera2
projection: {value: new Matrix4()}, // set in PerspectiveCamera2
saoBiasEpsilon: {value: new Vector4(1, 1, 1, 1)},
sceneBoundingRadius: {value: 0},
// split mode
ssaoSplitX: {value: 0.5},
},
vertexShader: shaderUtils.defaultVertex,
fragmentShader: ssaoPass,
}, 'tDiffuse') // why is tLastThis not here. because encoding and size doesnt matter?
this.needsSwap = false
this.clear = true
// this.bilateralPass = new BilateralFilterPass(this._target as any, gBufferUnpack, 'rrrr')
// this._multiplyPass = new GenericBlendTexturePass(this._target.texture as any, 'c = vec4((1.0-b.r) * a.xyz, a.a);')
// this._getUiConfig = this._getUiConfig.bind(this)
}
copyToWriteBuffer = false
render(renderer: IWebGLRenderer, writeBuffer: WebGLRenderTarget, readBuffer: WebGLRenderTarget, deltaTime: number, maskActive: boolean) {
this.needsSwap = false
if (!this.enabled) return
const target = getOrCall(this.target)
if (!target) {
console.warn('SSAOPluginPass: target not defined')
return
}
this._updateParameters()
// if (!this.material.defines.HAS_GBUFFER) {
// console.warn('SSAOPluginPass: DepthNormalBuffer required for ssao')
// }
// tLastThis is not used anymore. the ssao is merged across frames with progressive plugin
// renderer.renderManager.blit(writeBuffer, {
// source: target.texture,
// respectColorSpace: true,
// })
// this.uniforms.tLastThis.value = writeBuffer.texture
super.render(renderer, target, readBuffer, deltaTime, maskActive)
// todo
// if (this.smoothEnabled) {
// this.bilateralPass.render(renderer, writeBuffer, readBuffer, deltaTime, maskActive)
// }
if (this.copyToWriteBuffer) {
renderer.renderManager.blit(writeBuffer, {
source: target.texture,
respectColorSpace: true,
})
this.needsSwap = true
}
}
private _projScale = 1
private _updateParameters() {
// const projectionScale = 1 / (Math.tan(DEG2RAD * (camera as any).fov / 2) * 2);
const saoData = this.material.uniforms.saoData.value
// saoData.x = projectionScale;
saoData.y = this.intensity
saoData.z = this.occlusionWorldRadius
// saoData.w = this.accIndex_++;
saoData.z *= this._projScale * 0.25//* 100 / 2
const saoBiasEpsilon = this.material.uniforms.saoBiasEpsilon.value
saoBiasEpsilon.x = this.bias
saoBiasEpsilon.y = 0.001
saoBiasEpsilon.z = this.falloff
if (this.autoRadius) {
saoBiasEpsilon.w = Math.min(this.material.uniforms.sceneBoundingRadius.value, 100)
} else {
saoBiasEpsilon.w = 1
}
// this.material.uniforms.size.value.set(this._target.texture.image?.width, this._target.texture.image?.height)
}
beforeRender(scene: IScene, camera: ICamera, renderManager: IRenderManager) {
if (!this.enabled) return
this.updateShaderProperties([camera, renderManager, scene])
const fov = Math.max(1, (scene.mainCamera as PerspectiveCamera2).fov ?? 1)
const h = renderManager?.webglRenderer.domElement.height || 1
const w = 1 // renderManager?.webglRenderer.domElement.width || 1
this._projScale = h / (2 * w * Math.tan(0.5 * fov * MathUtils.DEG2RAD))
}
readonly materialExtension: MaterialExtension = {
extraUniforms: {
tSSAOMap: ()=>({value: getOrCall(this.target)?.texture ?? null}),
ssaoSplitX: this.material.uniforms.ssaoSplitX,
},
shaderExtender: (shader, _material, _renderer) => {
if (!shader.defines?.SSAO_ENABLED) return
// todo: only SSAO_PACKING = 1 and 2 are supported. Not 3 and 4 right now.
shader.fragmentShader = shaderReplaceString(shader.fragmentShader, '#include <aomap_fragment>', ssaoPatch)
},
onObjectRender: (_object, material, renderer: any) => {
// const opaque = !material.transparent && (!material.transmission || material.transmission < 0.001)
const x: any = this.enabled && // opaque &&
renderer.userData.screenSpaceRendering !== false &&
!material.userData?.pluginsDisabled &&
!material.userData?.ssaoDisabled ? this.split > 0 ? 2 : 1 : 0
if (material.defines!.SSAO_ENABLED !== x) {
material.defines!.SSAO_ENABLED = x
material.needsUpdate = true
}
},
parsFragmentSnippet: ()=>glsl`
uniform sampler2D tSSAOMap;
#if defined(SSAO_ENABLED) && SSAO_ENABLED == 2
uniform float ssaoSplitX;
#endif
${getTexelDecoding('tSSAOMap', getOrCall(this.target)?.texture.colorSpace)}
#include <simpleCameraHelpers>
`,
computeCacheKey: () => {
return (this.enabled ? '1' : '0') + getOrCall(this.target)?.texture?.colorSpace
},
uuid: SSAOPlugin.PluginType,
...uiConfigMaterialExtension(this._getUiConfig.bind(this), SSAOPlugin.PluginType),
isCompatible: material => {
return (material as PhysicalMaterial).isPhysicalMaterial
},
}
/**
* Returns a uiConfig to toggle SSAO on a material.
* This uiConfig is added to each material by extension
* @param material
* @private
*/
protected _getUiConfig(material: IMaterial) {
return {
type: 'folder',
label: 'SSAO',
children: [
{
type: 'checkbox',
label: 'Enabled',
get value() {
return !(material.userData.ssaoDisabled ?? false)
},
set value(v) {
if (v === !(material.userData.ssaoDisabled ?? false)) return
material.userData.ssaoDisabled = !v
material.setDirty()
},
onChange: this.setDirty,
},
{
type: 'checkbox',
label: 'Cast SSAO',
get value() {
return !(material.userData.ssaoCastDisabled ?? false)
},
set value(v) {
if (v === !(material.userData.ssaoCastDisabled ?? false)) return
material.userData.ssaoCastDisabled = !v
material.setDirty()
},
onChange: this.setDirty,
},
],
}
}
}
declare module '../../core/IMaterial' {
interface IMaterialUserData {
/**
* Disable SSAOPlugin for this material.
*/
ssaoDisabled?: boolean
/**
* Cast SSAO on other objects.
* if casting is not working when this is false, ensure render to depth is true, like for transparent objects
*/
ssaoCastDisabled?: boolean
}
}