Parcourir la source

WebGPURenderer: Support clipping (#27691)

* clipping

* add ;

* remove testing code

* cleanup and adjust default settiing in example

* remove unused import

* cleanup ClippingNode

* combine methods

* rework to increase efficiency

* update screenshot

* fix asyncCompile

* fix against upstream

* cleanup

* simplify

* exclude tests

---------

Co-authored-by: aardgoose <[email protected]>
aardgoose il y a 1 an
Parent
commit
158d8a10d4

+ 1 - 0
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",

+ 144 - 0
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 ) );

+ 2 - 0
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;

+ 30 - 0
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;

+ 165 - 0
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;

+ 43 - 0
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 + ',';

+ 3 - 1
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() ) {
 

+ 44 - 3
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;
+
+					}
+
+				}
 
 			}
 

+ 1 - 0
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 );

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

BIN
examples/screenshots/webgpu_clipping.jpg


+ 291 - 0
examples/webgpu_clipping.html

@@ -0,0 +1,291 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js webgpu - clipping planes</title>
+		<meta charset="utf-8">
+		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+		<link type="text/css" rel="stylesheet" href="main.css">
+	</head>
+	<body>
+		<div id="info">
+			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> webgpu - clipping
+		</div>
+		<script type="importmap">
+			{
+				"imports": {
+					"three": "../build/three.module.js",
+					"three/addons/": "./jsm/",
+					"three/nodes": "./jsm/nodes/Nodes.js"
+				}
+			}
+		</script>
+
+		<script type="module">
+
+			import * as THREE from 'three';
+			import { MeshPhongNodeMaterial } from 'three/nodes';
+
+			import WebGPU from 'three/addons/capabilities/WebGPU.js';
+			import WebGL from 'three/addons/capabilities/WebGL.js';
+
+			import WebGPURenderer from 'three/addons/renderers/webgpu/WebGPURenderer.js';
+
+			import Stats from 'three/addons/libs/stats.module.js';
+
+			import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
+
+			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+
+			let camera, scene, renderer, startTime, object, stats;
+
+			init();
+			animate();
+
+			function init() {
+
+				if ( WebGPU.isAvailable() === false && WebGL.isWebGL2Available() === false ) {
+
+					document.body.appendChild( WebGPU.getErrorMessage() );
+
+					throw new Error( 'No WebGPU or WebGL2 support' );
+
+				}
+
+				camera = new THREE.PerspectiveCamera( 36, window.innerWidth / window.innerHeight, 0.25, 16 );
+
+				camera.position.set( 0, 1.3, 3 );
+
+				scene = new THREE.Scene();
+
+				// Lights
+
+				scene.add( new THREE.AmbientLight( 0xcccccc ) );
+
+				const spotLight = new THREE.SpotLight( 0xffffff, 60 );
+				spotLight.angle = Math.PI / 5;
+				spotLight.penumbra = 0.2;
+				spotLight.position.set( 2, 3, 3 );
+				spotLight.castShadow = true;
+				spotLight.shadow.camera.near = 3;
+				spotLight.shadow.camera.far = 10;
+				spotLight.shadow.mapSize.width = 2048;
+				spotLight.shadow.mapSize.height = 2048;
+				spotLight.shadow.bias = - 0.002;
+				spotLight.shadow.radius = 4;
+				scene.add( spotLight );
+
+				const dirLight = new THREE.DirectionalLight( 0x55505a, 3 );
+				dirLight.position.set( 0, 3, 0 );
+				dirLight.castShadow = true;
+				dirLight.shadow.camera.near = 1;
+				dirLight.shadow.camera.far = 10;
+
+				dirLight.shadow.camera.right = 1;
+				dirLight.shadow.camera.left = - 1;
+				dirLight.shadow.camera.top	= 1;
+				dirLight.shadow.camera.bottom = - 1;
+
+				dirLight.shadow.mapSize.width = 1024;
+				dirLight.shadow.mapSize.height = 1024;
+				scene.add( dirLight );
+
+				// ***** Clipping planes: *****
+
+				const localPlane = new THREE.Plane( new THREE.Vector3( 0, - 1, 0 ), 0.8 );
+				const localPlane2 = new THREE.Plane( new THREE.Vector3( 0, 0, -1 ), 0.1 );
+				const globalPlane = new THREE.Plane( new THREE.Vector3( - 1, 0, 0 ), 0.1 );
+
+				// Geometry
+
+				const material = new MeshPhongNodeMaterial( {
+					color: 0x80ee10,
+					shininess: 0,
+					side: THREE.DoubleSide,
+
+					// ***** Clipping setup (material): *****
+					clippingPlanes: [ localPlane, localPlane2 ],
+					clipShadows: true,
+					alphaToCoverage: true,
+					clipIntersection: true
+
+				} );
+
+				material.isClipping = true;
+				const geometry = new THREE.TorusKnotGeometry( 0.4, 0.08, 95, 20 );
+
+				object = new THREE.Mesh( geometry, material );
+				object.castShadow = true;
+				scene.add( object );
+
+				const ground = new THREE.Mesh(
+					new THREE.PlaneGeometry( 9, 9, 1, 1 ),
+					new MeshPhongNodeMaterial( { color: 0xa0adaf, shininess: 150 } )
+				);
+
+				ground.rotation.x = - Math.PI / 2; // rotates X/Y to X/Z
+				ground.receiveShadow = true;
+				scene.add( ground );
+
+				// Stats
+
+				stats = new Stats();
+				document.body.appendChild( stats.dom );
+
+				// Renderer
+
+				renderer = new WebGPURenderer( { antialias: true } );
+				renderer.shadowMap.enabled = true;
+				renderer.setPixelRatio( window.devicePixelRatio );
+				renderer.setSize( window.innerWidth, window.innerHeight );
+				window.addEventListener( 'resize', onWindowResize );
+				document.body.appendChild( renderer.domElement );
+
+				// ***** Clipping setup (renderer): *****
+				const globalPlanes = [ globalPlane ];
+				const Empty = Object.freeze( [] );
+
+				renderer.clippingPlanes = Empty; // GUI sets it to globalPlanes
+				renderer.localClippingEnabled = true;
+
+				// Controls
+				const controls = new OrbitControls( camera, renderer.domElement );
+				controls.target.set( 0, 1, 0 );
+				controls.update();
+
+				// GUI
+
+				const gui = new GUI(),
+					props = {
+						alphaToCoverage: true,
+					},
+					folderLocal = gui.addFolder( 'Local Clipping' ),
+					propsLocal = {
+
+						get 'Enabled'() {
+
+							return renderer.localClippingEnabled;
+
+						},
+						set 'Enabled'( v ) {
+
+							renderer.localClippingEnabled = v;
+
+						},
+
+						get 'Shadows'() {
+
+							return material.clipShadows;
+
+						},
+						set 'Shadows'( v ) {
+
+							material.clipShadows = v;
+
+						},
+
+						get 'Intersection'() {
+
+							return material.clipIntersection;
+
+						},
+
+						set 'Intersection'( v ) {
+
+							material.clipIntersection = v;
+
+						},
+
+						get 'Plane'() {
+
+							return localPlane.constant;
+
+						},
+						set 'Plane'( v ) {
+
+							localPlane.constant = v;
+
+						}
+
+					},
+
+					folderGlobal = gui.addFolder( 'Global Clipping' ),
+					propsGlobal = {
+
+						get 'Enabled'() {
+
+							return renderer.clippingPlanes !== Empty;
+
+						},
+						set 'Enabled'( v ) {
+
+							renderer.clippingPlanes = v ? globalPlanes : Empty;
+
+						},
+
+						get 'Plane'() {
+
+							return globalPlane.constant;
+
+						},
+						set 'Plane'( v ) {
+
+							globalPlane.constant = v;
+
+						}
+
+					};
+
+				gui.add( props, 'alphaToCoverage' ).onChange( function ( value ) {
+
+					ground.material.alphaToCoverage = value;
+					ground.material.needsUpdate = true;
+
+					material.alphaToCoverage = value;
+					material.needsUpdate = true;
+
+				} );
+
+				folderLocal.add( propsLocal, 'Enabled' );
+				folderLocal.add( propsLocal, 'Shadows' );
+				folderLocal.add( propsLocal, 'Intersection' );
+				folderLocal.add( propsLocal, 'Plane', 0.3, 1.25 );
+
+				folderGlobal.add( propsGlobal, 'Enabled' );
+				folderGlobal.add( propsGlobal, 'Plane', - 0.4, 3 );
+
+				// Start
+
+				startTime = Date.now();
+
+			}
+
+			function onWindowResize() {
+
+				camera.aspect = window.innerWidth / window.innerHeight;
+				camera.updateProjectionMatrix();
+
+				renderer.setSize( window.innerWidth, window.innerHeight );
+
+			}
+
+			function animate( currentTime ) {
+
+				const time = ( currentTime - startTime ) / 1000;
+
+				requestAnimationFrame( animate );
+
+				object.position.y = 0.8;
+				object.rotation.x = time * 0.5;
+				object.rotation.y = time * 0.2;
+				object.scale.setScalar( Math.cos( time ) * 0.125 + 0.875 );
+
+				stats.begin();
+				renderer.render( scene, camera );
+				stats.end();
+
+			}
+
+		</script>
+
+	</body>
+</html>

+ 1 - 0
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',