diff --git a/examples/files.json b/examples/files.json index a912ac96fe03e4..12152d8fd7b153 100644 --- a/examples/files.json +++ b/examples/files.json @@ -321,6 +321,7 @@ "webgpu_backdrop_water", "webgpu_camera_logarithmicdepthbuffer", "webgpu_clearcoat", + "webgpu_clipping", "webgpu_compute_audio", "webgpu_compute_particles", "webgpu_compute_particles_rain", diff --git a/examples/jsm/nodes/accessors/ClippingNode.js b/examples/jsm/nodes/accessors/ClippingNode.js new file mode 100644 index 00000000000000..b3f67c84c8b0a6 --- /dev/null +++ b/examples/jsm/nodes/accessors/ClippingNode.js @@ -0,0 +1,144 @@ + +import Node from '../core/Node.js'; +import { nodeObject } from '../shadernode/ShaderNode.js'; +import { positionView } from './PositionNode.js'; +import { diffuseColor, property } from '../core/PropertyNode.js'; +import { tslFn } from '../shadernode/ShaderNode.js'; +import { loop } from '../utils/LoopNode.js'; +import { smoothstep } from '../math/MathNode.js'; +import { uniforms } from './UniformsNode.js'; + +class ClippingNode extends Node { + + constructor( scope = ClippingNode.DEFAULT ) { + + super(); + + this.scope = scope; + + } + + setup( builder ) { + + super.setup( builder ); + + const clippingContext = builder.clippingContext; + const { localClipIntersection, localClippingCount, globalClippingCount } = clippingContext; + + const numClippingPlanes = globalClippingCount + localClippingCount; + const numUnionClippingPlanes = localClipIntersection ? numClippingPlanes - localClippingCount : numClippingPlanes; + + if ( this.scope === ClippingNode.ALPHA_TO_COVERAGE ) { + + return this.setupAlphaToCoverage( clippingContext.planes, numClippingPlanes, numUnionClippingPlanes ); + + } else { + + return this.setupDefault( clippingContext.planes, numClippingPlanes, numUnionClippingPlanes ); + + } + + } + + setupAlphaToCoverage( planes, numClippingPlanes, numUnionClippingPlanes ) { + + return tslFn( () => { + + const clippingPlanes = uniforms( planes ); + + const distanceToPlane = property( 'float', 'distanceToPlane' ); + const distanceGradient = property( 'float', 'distanceToGradient' ); + + const clipOpacity = property( 'float', 'clipOpacity' ); + + clipOpacity.assign( 1 ); + + let plane; + + loop( numUnionClippingPlanes, ( { i } ) => { + + plane = clippingPlanes.element( i ); + + distanceToPlane.assign( positionView.dot( plane.xyz ).negate().add( plane.w ) ); + distanceGradient.assign( distanceToPlane.fwidth().div( 2.0 ) ); + + clipOpacity.mulAssign( smoothstep( distanceGradient.negate(), distanceGradient, distanceToPlane ) ); + + clipOpacity.equal( 0.0 ).discard(); + + } ); + + if ( numUnionClippingPlanes < numClippingPlanes ) { + + const unionClipOpacity = property( 'float', 'unionclipOpacity' ); + + unionClipOpacity.assign( 1 ); + + loop( { start: numUnionClippingPlanes, end: numClippingPlanes }, ( { i } ) => { + + plane = clippingPlanes.element( i ); + + distanceToPlane.assign( positionView.dot( plane.xyz ).negate().add( plane.w ) ); + distanceGradient.assign( distanceToPlane.fwidth().div( 2.0 ) ); + + unionClipOpacity.mulAssign( smoothstep( distanceGradient.negate(), distanceGradient, distanceToPlane ).oneMinus() ); + + } ); + + clipOpacity.mulAssign( unionClipOpacity.oneMinus() ); + + } + + diffuseColor.a.mulAssign( clipOpacity ); + + diffuseColor.a.equal( 0.0 ).discard(); + + } )(); + + } + + setupDefault( planes, numClippingPlanes, numUnionClippingPlanes ) { + + return tslFn( () => { + + const clippingPlanes = uniforms( planes ); + + let plane; + + loop( numUnionClippingPlanes, ( { i } ) => { + + plane = clippingPlanes.element( i ); + positionView.dot( plane.xyz ).greaterThan( plane.w ).discard(); + + } ); + + if ( numUnionClippingPlanes < numClippingPlanes ) { + + const clipped = property( 'bool', 'clipped' ); + + clipped.assign( true ); + + loop( { start: numUnionClippingPlanes, end: numClippingPlanes }, ( { i } ) => { + + plane = clippingPlanes.element( i ); + clipped.assign( positionView.dot( plane.xyz ).greaterThan( plane.w ).and( clipped ) ); + + } ); + + clipped.discard(); + } + + } )(); + + } + +} + +ClippingNode.ALPHA_TO_COVERAGE = 'alphaToCoverage'; +ClippingNode.DEFAULT = 'default'; + +export default ClippingNode; + +export const clipping = () => nodeObject( new ClippingNode() ); + +export const clippingAlpha = () => nodeObject( new ClippingNode( ClippingNode.ALPHA_TO_COVERAGE ) ); diff --git a/examples/jsm/nodes/core/NodeBuilder.js b/examples/jsm/nodes/core/NodeBuilder.js index 4d0e90e37dfae4..6dd15892b96afc 100644 --- a/examples/jsm/nodes/core/NodeBuilder.js +++ b/examples/jsm/nodes/core/NodeBuilder.js @@ -72,6 +72,8 @@ class NodeBuilder { this.fogNode = null; this.toneMappingNode = null; + this.clippingContext = null; + this.vertexShader = null; this.fragmentShader = null; this.computeShader = null; diff --git a/examples/jsm/nodes/materials/NodeMaterial.js b/examples/jsm/nodes/materials/NodeMaterial.js index 325a08285e93b9..dbe5d6c596ccaa 100644 --- a/examples/jsm/nodes/materials/NodeMaterial.js +++ b/examples/jsm/nodes/materials/NodeMaterial.js @@ -19,6 +19,7 @@ import { lightingContext } from '../lighting/LightingContextNode.js'; import EnvironmentNode from '../lighting/EnvironmentNode.js'; import { depthPixel } from '../display/ViewportDepthNode.js'; import { cameraLogDepth } from '../accessors/CameraNode.js'; +import { clipping, clippingAlpha } from '../accessors/ClippingNode.js'; const NodeMaterials = new Map(); @@ -90,6 +91,8 @@ class NodeMaterial extends ShaderMaterial { let resultNode; + const clippingNode = this.setupClipping( builder ); + if ( this.fragmentNode === null ) { if ( this.depthWrite === true ) this.setupDepth( builder ); @@ -101,6 +104,8 @@ class NodeMaterial extends ShaderMaterial { const outgoingLightNode = this.setupLighting( builder ); + if ( clippingNode !== null ) builder.stack.add( clippingNode ); + resultNode = this.setupOutput( builder, vec4( outgoingLightNode, diffuseColor.a ) ); // OUTPUT NODE @@ -123,6 +128,31 @@ class NodeMaterial extends ShaderMaterial { } + setupClipping( builder ) { + + const { globalClippingCount, localClippingCount } = builder.clippingContext; + + let result = null; + + if ( globalClippingCount || localClippingCount ) { + + if ( this.alphaToCoverage ) { + + // to be added to flow when the color/alpha value has been determined + result = clippingAlpha(); + + } else { + + builder.stack.add( clipping() ); + + } + + } + + return result; + + } + setupDepth( builder ) { const { renderer } = builder; diff --git a/examples/jsm/renderers/common/ClippingContext.js b/examples/jsm/renderers/common/ClippingContext.js new file mode 100644 index 00000000000000..085e5d591abc26 --- /dev/null +++ b/examples/jsm/renderers/common/ClippingContext.js @@ -0,0 +1,165 @@ +import { Matrix3, Plane, Vector4 } from 'three'; + +const _plane = new Plane(); +const _viewNormalMatrix = new Matrix3(); + +let _clippingContextVersion = 0; + +class ClippingContext { + + constructor() { + + this.version = ++ _clippingContextVersion; + + this.globalClippingCount = 0; + + this.localClippingCount = 0; + this.localClippingEnabled = false; + this.localClipIntersection = false; + + this.planes = []; + + this.parentVersion = 0; + + } + + projectPlanes( source, offset ) { + + const l = source.length; + const planes = this.planes; + + for ( let i = 0; i < l; i ++ ) { + + _plane.copy( source[ i ] ).applyMatrix4( this.viewMatrix, _viewNormalMatrix ); + + const v = planes[ offset + i ]; + const normal = _plane.normal; + + v.x = - normal.x; + v.y = - normal.y; + v.z = - normal.z; + v.w = _plane.constant; + + } + + } + + updateGlobal( renderer, camera ) { + + const rendererClippingPlanes = renderer.clippingPlanes; + this.viewMatrix = camera.matrixWorldInverse; + + _viewNormalMatrix.getNormalMatrix( this.viewMatrix ); + + let update = false; + + if ( Array.isArray( rendererClippingPlanes ) && rendererClippingPlanes.length !== 0 ) { + + const l = rendererClippingPlanes.length; + + if ( l !== this.globalClippingCount ) { + + const planes = []; + + for ( let i = 0; i < l; i ++ ) { + + planes.push( new Vector4() ); + + } + + this.globalClippingCount = l; + this.planes = planes; + + update = true; + + } + + this.projectPlanes( rendererClippingPlanes, 0 ); + + } else if ( this.globalClippingCount !== 0 ) { + + this.globalClippingCount = 0; + this.planes = []; + update = true; + + } + + if ( renderer.localClippingEnabled !== this.localClippingEnabled ) { + + this.localClippingEnabled = renderer.localClippingEnabled; + update = true; + + } + + if ( update ) this.version = _clippingContextVersion ++; + + } + + update( parent, material ) { + + let update = false; + + if ( this !== parent && parent.version !== this.parentVersion ) { + + this.globalClippingCount = material.isShadowNodeMaterial ? 0 : parent.globalClippingCount; + this.localClippingEnabled = parent.localClippingEnabled; + this.planes = Array.from( parent.planes ); + this.parentVersion = parent.version; + this.viewMatrix = parent.viewMatrix; + + + update = true; + + } + + if ( this.localClippingEnabled ) { + + const localClippingPlanes = material.clippingPlanes; + + if ( ( Array.isArray( localClippingPlanes ) && localClippingPlanes.length !== 0 ) ) { + + const l = localClippingPlanes.length; + const planes = this.planes; + const offset = this.globalClippingCount; + + if ( update || l !== this.localClippingCount ) { + + planes.length = offset + l; + + for ( let i = 0; i < l; i ++ ) { + + planes[ offset + i ] = new Vector4(); + + } + + this.localClippingCount = l; + update = true; + + } + + this.projectPlanes( localClippingPlanes, offset ); + + + } else if ( this.localClippingCount !== 0 ) { + + this.localClippingCount = 0; + update = true; + + } + + if ( this.localClipIntersection !== material.clipIntersection ) { + + this.localClipIntersection = material.clipIntersection; + update = true; + + } + + } + + if ( update ) this.version = _clippingContextVersion ++; + + } + +} + +export default ClippingContext; diff --git a/examples/jsm/renderers/common/RenderObject.js b/examples/jsm/renderers/common/RenderObject.js index 5372250b8bcadd..2f0c0872444d41 100644 --- a/examples/jsm/renderers/common/RenderObject.js +++ b/examples/jsm/renderers/common/RenderObject.js @@ -1,3 +1,5 @@ +import ClippingContext from "./ClippingContext.js"; + let id = 0; export default class RenderObject { @@ -24,6 +26,10 @@ export default class RenderObject { this.pipeline = null; this.vertexBuffers = null; + this.updateClipping( renderContext.clippingContext ); + + this.clippingContextVersion = this.clippingContext.version; + this.initialNodesCacheKey = this.getNodesCacheKey(); this.initialCacheKey = this.getCacheKey(); @@ -44,6 +50,41 @@ export default class RenderObject { } + updateClipping( parent ) { + + const material = this.material; + + let clippingContext = this.clippingContext; + + if ( Array.isArray( material.clippingPlanes ) ) { + + if ( clippingContext === parent || ! clippingContext ) { + + clippingContext = new ClippingContext(); + this.clippingContext = clippingContext; + + } + + clippingContext.update( parent, material ); + + } else if ( this.clippingContext !== parent ) { + + this.clippingContext = parent; + + } + + } + + clippingNeedsUpdate () { + + if ( this.clippingContext.version === this.clippingContextVersion ) return false; + + this.clippingContextVersion = this.clippingContext.version; + + return true; + + } + getNodeBuilderState() { return this._nodeBuilderState || ( this._nodeBuilderState = this._nodes.getForRender( this ) ); @@ -131,6 +172,8 @@ export default class RenderObject { } + cacheKey += this.clippingContextVersion + ','; + if ( object.skeleton ) { cacheKey += object.skeleton.uuid + ','; diff --git a/examples/jsm/renderers/common/RenderObjects.js b/examples/jsm/renderers/common/RenderObjects.js index f223c38a917af2..e4bd3222cde20f 100644 --- a/examples/jsm/renderers/common/RenderObjects.js +++ b/examples/jsm/renderers/common/RenderObjects.js @@ -31,7 +31,9 @@ class RenderObjects { } else { - if ( renderObject.version !== material.version || renderObject.needsUpdate ) { + renderObject.updateClipping( renderContext.clippingContext ); + + if ( renderObject.version !== material.version || renderObject.needsUpdate || renderObject.clippingNeedsUpdate() ) { if ( renderObject.initialCacheKey !== renderObject.getCacheKey() ) { diff --git a/examples/jsm/renderers/common/Renderer.js b/examples/jsm/renderers/common/Renderer.js index 41a05a361664c6..9a5f3ee11cb8dd 100644 --- a/examples/jsm/renderers/common/Renderer.js +++ b/examples/jsm/renderers/common/Renderer.js @@ -11,6 +11,7 @@ import Textures from './Textures.js'; import Background from './Background.js'; import Nodes from './nodes/Nodes.js'; import Color4 from './Color4.js'; +import ClippingContext from './ClippingContext.js'; import { Scene, Frustum, Matrix4, Vector2, Vector3, Vector4, DoubleSide, BackSide, FrontSide, SRGBColorSpace, NoToneMapping } from 'three'; const _scene = new Scene(); @@ -58,6 +59,8 @@ class Renderer { this.depth = true; this.stencil = true; + this.clippingPlanes = []; + this.info = new Info(); // internals @@ -223,6 +226,9 @@ class Renderer { renderContext.depth = this.depth; renderContext.stencil = this.stencil; + if ( ! renderContext.clippingContext ) renderContext.clippingContext = new ClippingContext(); + renderContext.clippingContext.updateGlobal( this, camera ); + // sceneRef.onBeforeRender( this, scene, camera, renderTarget ); @@ -386,6 +392,9 @@ class Renderer { renderContext.scissorValue.width >>= activeMipmapLevel; renderContext.scissorValue.height >>= activeMipmapLevel; + if ( ! renderContext.clippingContext ) renderContext.clippingContext = new ClippingContext(); + renderContext.clippingContext.updateGlobal( this, camera ); + // sceneRef.onBeforeRender( this, scene, camera, renderTarget ); @@ -1107,10 +1116,42 @@ class Renderer { } - if ( overrideMaterial.isShadowNodeMaterial && ( material.shadowNode && material.shadowNode.isNode ) ) { + if ( overrideMaterial.isShadowNodeMaterial ) { + + overrideMaterial.side = material.shadowSide === null ? material.side : material.shadowSide; + + if ( material.shadowNode && material.shadowNode.isNode ) { + + overrideFragmentNode = overrideMaterial.fragmentNode; + overrideMaterial.fragmentNode = material.shadowNode; + + } + + if ( this.localClippingEnabled ) { + + if ( material.clipShadows ) { + + if ( overrideMaterial.clippingPlanes !== material.clippingPlanes ) { + + overrideMaterial.clippingPlanes = material.clippingPlanes; + overrideMaterial.needsUpdate = true; - overrideFragmentNode = overrideMaterial.fragmentNode; - overrideMaterial.fragmentNode = material.shadowNode; + } + + if ( overrideMaterial.clipIntersection !== material.clipIntersection ) { + + overrideMaterial.clipIntersection = material.clipIntersection; + + } + + } else if ( Array.isArray( overrideMaterial.clippingPlanes ) ) { + + overrideMaterial.clippingPlanes = null; + overrideMaterial.needsUpdate = true; + + } + + } } diff --git a/examples/jsm/renderers/common/nodes/Nodes.js b/examples/jsm/renderers/common/nodes/Nodes.js index 2364ee5d9ceb59..5e217a3c705bdb 100644 --- a/examples/jsm/renderers/common/nodes/Nodes.js +++ b/examples/jsm/renderers/common/nodes/Nodes.js @@ -114,6 +114,7 @@ class Nodes extends DataMap { nodeBuilder.environmentNode = this.getEnvironmentNode( renderObject.scene ); nodeBuilder.fogNode = this.getFogNode( renderObject.scene ); nodeBuilder.toneMappingNode = this.getToneMappingNode(); + nodeBuilder.clippingContext = renderObject.clippingContext; nodeBuilder.build(); nodeBuilderState = this._createNodeBuilderState( nodeBuilder ); diff --git a/examples/jsm/renderers/webgpu/WebGPUBackend.js b/examples/jsm/renderers/webgpu/WebGPUBackend.js index 8a4686c2b9d535..56c8548a6a6632 100644 --- a/examples/jsm/renderers/webgpu/WebGPUBackend.js +++ b/examples/jsm/renderers/webgpu/WebGPUBackend.js @@ -929,7 +929,8 @@ class WebGPUBackend extends Backend { data.side !== material.side || data.alphaToCoverage !== material.alphaToCoverage || data.sampleCount !== sampleCount || data.colorSpace !== colorSpace || data.colorFormat !== colorFormat || data.depthStencilFormat !== depthStencilFormat || - data.primitiveTopology !== primitiveTopology + data.primitiveTopology !== primitiveTopology || + data.clippingContextVersion !== renderObject.clippingContextVersion ) { data.material = material; data.materialVersion = material.version; @@ -947,6 +948,7 @@ class WebGPUBackend extends Backend { data.colorFormat = colorFormat; data.depthStencilFormat = depthStencilFormat; data.primitiveTopology = primitiveTopology; + data.clippingContextVersion = renderObject.clippingContextVersion; needsUpdate = true; @@ -975,7 +977,8 @@ class WebGPUBackend extends Backend { material.side, utils.getSampleCount( renderContext ), utils.getCurrentColorSpace( renderContext ), utils.getCurrentColorFormat( renderContext ), utils.getCurrentDepthStencilFormat( renderContext ), - utils.getPrimitiveTopology( object, material ) + utils.getPrimitiveTopology( object, material ), + renderObject.clippingContextVersion ].join(); } diff --git a/examples/screenshots/webgpu_clipping.jpg b/examples/screenshots/webgpu_clipping.jpg new file mode 100644 index 00000000000000..e93b3c0f398d17 Binary files /dev/null and b/examples/screenshots/webgpu_clipping.jpg differ diff --git a/examples/webgpu_clipping.html b/examples/webgpu_clipping.html new file mode 100644 index 00000000000000..b672ca06485ebe --- /dev/null +++ b/examples/webgpu_clipping.html @@ -0,0 +1,291 @@ + + + + three.js webgpu - clipping planes + + + + + +
+ three.js webgpu - clipping +
+ + + + + + diff --git a/test/e2e/puppeteer.js b/test/e2e/puppeteer.js index 59134be10c8ba0..fe01ea5ccd622a 100644 --- a/test/e2e/puppeteer.js +++ b/test/e2e/puppeteer.js @@ -126,6 +126,7 @@ const exceptionList = [ 'webgpu_postprocessing_afterimage', 'webgpu_backdrop_water', 'webgpu_camera_logarithmicdepthbuffer', + 'webgpu_clipping', 'webgpu_loader_materialx', 'webgpu_materials_video', 'webgpu_materialx_noise',