diff --git a/examples/jsm/mirrored/MirroredCullingApp.js b/examples/jsm/mirrored/MirroredCullingApp.js new file mode 100644 index 00000000000000..1f3ebf2244d19c --- /dev/null +++ b/examples/jsm/mirrored/MirroredCullingApp.js @@ -0,0 +1,659 @@ +/* eslint-disable */ +import { OrbitControls } from '../controls/OrbitControls.js'; +import { GLTFLoader } from '../loaders/GLTFLoader.js'; +import { GUI } from '../libs/lil-gui.module.min.js'; + +export async function initMirroredCullingApp( THREE, renderer, options = {} ) { + + // GUI-only; no legacy DOM UI elements + + // UI state for projection/view/scene flips + const params = { + arrayCameraEnabled: false, // enable/disable array camera mode + flipMode: 'all', // all, odd, even, mod3 + camera: { type: 'Perspective', fov: 75, orthoSize: 10, near: 0.1, far: 100 }, + projection: { x: true, y: false, z: false }, // default mirror X + view: { x: false, y: false, z: false }, + scene: { x: false, y: false, z: false } + }; + + // renderer + renderer.setSize( window.innerWidth, window.innerHeight ); + document.body.appendChild( renderer.domElement ); + + // scene + const scene = new THREE.Scene(); + scene.background = new THREE.Color( 0x333333 ); + + // cameras + let mainCamera = new THREE.PerspectiveCamera( params.camera.fov, (window.innerWidth / 2) / window.innerHeight, params.camera.near, params.camera.far ); + mainCamera.name = 'mainCamera'; + mainCamera.position.z = 8; + + let mirroredCamera = new THREE.PerspectiveCamera( params.camera.fov, (window.innerWidth / 2) / window.innerHeight, params.camera.near, params.camera.far ); + mirroredCamera.name = 'mirroredCamera'; + mirroredCamera.position.z = 8; + + // Array camera variables + let mirroredCameras = []; + let mirroredCameraArray = null; + const ARRAY_SIZE = 2; // grid + + // lights + scene.add( new THREE.AmbientLight( 0xffffff, 1.5 ) ); + const dirLight = new THREE.DirectionalLight( 0xffffff, 2.5 ); + dirLight.position.set( 5, 5, 5 ); + scene.add( dirLight ); + + const hemi = new THREE.HemisphereLight( 0xffffff, 0x444444, 0.8 ); + hemi.position.set( 0, 10, 0 ); + scene.add( hemi ); + + const fill = new THREE.DirectionalLight( 0xffffff, 1.5 ); + fill.position.set( - 5, 3, - 5 ); + scene.add( fill ); + + const point = new THREE.PointLight( 0xffffff, 1.2, 50 ); + point.position.set( 0, 5, 5 ); + scene.add( point ); + + // loaders + const gltfLoader = new GLTFLoader(); + + // controls + let mainControls = new OrbitControls( mainCamera, renderer.domElement ); + let mirroredControls = new OrbitControls( mirroredCamera, renderer.domElement ); + + // mouse split control + let mouseX = 0; + window.addEventListener( 'mousemove', ( event ) => { mouseX = event.clientX; } ); + + // camera helpers for switching and resizing + function createCameraByType( type, aspect ) { + + let cam; + if ( type === 'Orthographic' ) { + const halfH = params.camera.orthoSize / 2; + const halfW = halfH * aspect; + cam = new THREE.OrthographicCamera( - halfW, halfW, halfH, - halfH, params.camera.near, params.camera.far ); + } else { + cam = new THREE.PerspectiveCamera( params.camera.fov, aspect, params.camera.near, params.camera.far ); + } + + cam.position.z = 8; + + cam.updateProjectionMatrix(); + return cam; + + } + + function updateCameraOnResize( cam ) { + + const aspect = ( window.innerWidth / 2 ) / window.innerHeight; + if ( cam.isPerspectiveCamera ) { + cam.aspect = aspect; + } else if ( cam.isOrthographicCamera ) { + const halfH = params.camera.orthoSize / 2; + const halfW = halfH * aspect; + cam.left = - halfW; + cam.right = halfW; + cam.top = halfH; + cam.bottom = - halfH; + } + cam.updateProjectionMatrix(); + + } + + function resizeCameras() { + + renderer.setSize( window.innerWidth, window.innerHeight ); + updateCameraOnResize( mainCamera ); + updateCameraOnResize( mirroredCamera ); + // Reapply projection flip state after any projection changes + applyProjectionState( mirroredCamera ); + + // Update camera array viewports if enabled + if ( params.arrayCameraEnabled && mirroredCameras.length ) { + const viewportWidth = window.innerWidth / 2 / ARRAY_SIZE; + const viewportHeight = window.innerHeight / ARRAY_SIZE; + + for ( let y = 0; y < ARRAY_SIZE; y ++ ) { + for ( let x = 0; x < ARRAY_SIZE; x ++ ) { + const index = y * ARRAY_SIZE + x; + const camera = mirroredCameras[ index ]; + + const viewportX = window.innerWidth / 2 + x * viewportWidth; + const viewportY = y * viewportHeight; + camera.viewport.set( viewportX, viewportY, viewportWidth, viewportHeight ); + } + } + } + + } + + // helpers applying state + function applyProjectionState( cam ) { + + cam.updateProjectionMatrix(); + const sx = params.projection.x ? - 1 : 1; + const sy = params.projection.y ? - 1 : 1; + const sz = params.projection.z ? - 1 : 1; + cam.projectionMatrix.scale( new THREE.Vector3( sx, sy, sz ) ); + + } + + function applyViewState( cam ) { + + const s = cam.scale; + s.x = ( params.view.x ? - 1 : 1 ) * Math.abs( s.x ); + s.y = ( params.view.y ? - 1 : 1 ) * Math.abs( s.y ); + s.z = ( params.view.z ? - 1 : 1 ) * Math.abs( s.z ); + cam.updateMatrixWorld(); + + } + + function applySceneState() { + + const s = scene.scale; + s.x = ( params.scene.x ? - 1 : 1 ) * Math.abs( s.x ); + s.y = ( params.scene.y ? - 1 : 1 ) * Math.abs( s.y ); + s.z = ( params.scene.z ? - 1 : 1 ) * Math.abs( s.z ); + scene.updateMatrixWorld( true ); + + } + + // Camera array functions + function createMirroredCameraArray() { + + mirroredCameras = []; + const aspect = (window.innerWidth / 2) / window.innerHeight; + const viewportWidth = window.innerWidth / 2 / ARRAY_SIZE; + const viewportHeight = window.innerHeight / ARRAY_SIZE; + + for ( let y = 0; y < ARRAY_SIZE; y ++ ) { + for ( let x = 0; x < ARRAY_SIZE; x ++ ) { + + const index = y * ARRAY_SIZE + x; + const camera = new THREE.PerspectiveCamera( params.camera.fov, aspect, params.camera.near, params.camera.far ); + camera.name = `mirroredCamera_${index}`; + + // Set viewport for this camera + const viewportX = window.innerWidth / 2 + x * viewportWidth; + const viewportY = y * viewportHeight; + camera.viewport = new THREE.Vector4( viewportX, viewportY, viewportWidth, viewportHeight ); + + // Position camera with slight rotation variation for demonstration + const angleOffset = (index / (ARRAY_SIZE * ARRAY_SIZE)) * Math.PI * 2; + const radius = 8; + const height = 2; + + camera.position.x = Math.cos(angleOffset) * radius; + camera.position.z = Math.sin(angleOffset) * radius; + camera.position.y = height + Math.sin(angleOffset * 2) * 2; + + // Look at center with slight tilt + const lookAt = new THREE.Vector3(0, 0, 0); + lookAt.y += Math.sin(angleOffset * 3) * 1; + camera.lookAt(lookAt); + + mirroredCameras.push(camera); + + } + } + + mirroredCameraArray = new THREE.ArrayCamera(mirroredCameras); + updateMirroredCameraArray(); + + } + + function updateMirroredCameraArray() { + + if ( ! mirroredCameras.length ) return; + + // Sync all array cameras with the main mirrored camera + for ( let i = 0; i < mirroredCameras.length; i ++ ) { + + const cam = mirroredCameras[ i ]; + cam.position.copy( mirroredCamera.position ); + cam.quaternion.copy( mirroredCamera.quaternion ); + cam.zoom = mirroredCamera.zoom; + cam.updateMatrixWorld(); + cam.updateProjectionMatrix(); + + // Apply selective flipping based on flip mode + if ( shouldFlipCamera( i ) ) { + applyViewState( cam ); + applyProjectionState( cam ); + } + + } + + } + + function shouldFlipCamera( index ) { + + switch ( params.flipMode ) { + case 'all': return true; + case 'odd': return index % 2 === 1; + case 'even': return index % 2 === 0; + case 'mod3': return index % 3 === 0; + default: return false; + } + + } + + function toggleArrayCameraMode() { + + if ( params.arrayCameraEnabled ) { + createMirroredCameraArray(); + } else { + mirroredCameras = []; + mirroredCameraArray = null; + } + + } + + // actions + let _syncing = false; + function syncFrom( source ) { + + if ( _syncing ) return; + _syncing = true; + + if ( source === 'main' ) { + + mirroredCamera.position.copy( mainCamera.position ); + mirroredCamera.quaternion.copy( mainCamera.quaternion ); + mirroredCamera.zoom = mainCamera.zoom; + mirroredCamera.updateMatrixWorld(); + mirroredCamera.updateProjectionMatrix(); + mirroredControls.target.copy( mainControls.target ); + // Ensure mirrored camera applies flip state after sync + applyViewState( mirroredCamera ); + applyProjectionState( mirroredCamera ); + mirroredControls.update(); + + // Update camera array if enabled + if ( params.arrayCameraEnabled ) { + updateMirroredCameraArray(); + } + + } else { + + mainCamera.position.copy( mirroredCamera.position ); + mainCamera.quaternion.copy( mirroredCamera.quaternion ); + mainCamera.zoom = mirroredCamera.zoom; + mainCamera.updateMatrixWorld(); + mainCamera.updateProjectionMatrix(); + mainControls.target.copy( mirroredControls.target ); + mainControls.update(); + + // Update camera array if enabled + if ( params.arrayCameraEnabled ) { + updateMirroredCameraArray(); + } + + } + + _syncing = false; + + } + + // auto-sync on OrbitControls changes (GUI-only flow: always on) + mainControls.addEventListener( 'change', () => { syncFrom( 'main' ); } ); + mirroredControls.addEventListener( 'change', () => { syncFrom( 'mirrored' ); } ); + + function rebuildCameras() { + + const aspect = ( window.innerWidth / 2 ) / window.innerHeight; + + // Create new cameras according to selection + mainCamera = createCameraByType( params.camera.type, aspect ); + mainCamera.name = 'mainCamera'; + mirroredCamera = createCameraByType( params.camera.type, aspect ); + mirroredCamera.name = 'mirroredCamera'; + + // Rebuild controls + if ( mainControls ) mainControls.dispose(); + if ( mirroredControls ) mirroredControls.dispose(); + mainControls = new OrbitControls( mainCamera, renderer.domElement ); + mirroredControls = new OrbitControls( mirroredCamera, renderer.domElement ); + mainControls.addEventListener( 'change', () => { syncFrom( 'main' ); } ); + mirroredControls.addEventListener( 'change', () => { syncFrom( 'mirrored' ); } ); + + // Ensure states are re-applied + applyViewState( mirroredCamera ); + updateCameraOnResize( mainCamera ); + updateCameraOnResize( mirroredCamera ); + applyProjectionState( mirroredCamera ); + + } + + function openUpGeometry( geometry ) { + + // Convert to non-indexed for easy triangle removal + const g = geometry.toNonIndexed(); + const posAttr = g.getAttribute( 'position' ); + const normAttr = g.getAttribute( 'normal' ); + const uvAttr = g.getAttribute( 'uv' ); + + const pos = posAttr.array; + const norm = normAttr ? normAttr.array : null; + const uv = uvAttr ? uvAttr.array : null; + + const keptPos = []; + const keptNorm = norm ? [] : null; + const keptUV = uv ? [] : null; + + const triCount = pos.length / 9; + for ( let t = 0; t < triCount; t ++ ) { + + const base = t * 9; + const x0 = pos[ base + 0 ], y0 = pos[ base + 1 ], z0 = pos[ base + 2 ]; + const x1 = pos[ base + 3 ], y1 = pos[ base + 4 ], z1 = pos[ base + 5 ]; + const x2 = pos[ base + 6 ], y2 = pos[ base + 7 ], z2 = pos[ base + 8 ]; + + const cx = ( x0 + x1 + x2 ) / 3; + const cz = ( z0 + z1 + z2 ) / 3; + + // Remove triangles in the +X +Z quadrant to open the mesh + if ( cx > 0 && cz > 0 ) continue; + + keptPos.push( + x0, y0, z0, + x1, y1, z1, + x2, y2, z2 + ); + + if ( keptNorm ) { + const nb = base; + keptNorm.push( + norm[ nb + 0 ], norm[ nb + 1 ], norm[ nb + 2 ], + norm[ nb + 3 ], norm[ nb + 4 ], norm[ nb + 5 ], + norm[ nb + 6 ], norm[ nb + 7 ], norm[ nb + 8 ] + ); + } + + if ( keptUV ) { + const ub = ( t * 6 ); + keptUV.push( + uv[ ub + 0 ], uv[ ub + 1 ], + uv[ ub + 2 ], uv[ ub + 3 ], + uv[ ub + 4 ], uv[ ub + 5 ] + ); + } + + } + + const out = new THREE.BufferGeometry(); + out.setAttribute( 'position', new THREE.Float32BufferAttribute( keptPos, 3 ) ); + if ( keptNorm ) out.setAttribute( 'normal', new THREE.Float32BufferAttribute( keptNorm, 3 ) ); + if ( keptUV ) out.setAttribute( 'uv', new THREE.Float32BufferAttribute( keptUV, 2 ) ); + + out.computeVertexNormals(); + out.computeBoundingBox(); + out.computeBoundingSphere(); + + return out; + + } + + // GUI (reuse injected GUI if provided) + const uiContainer = document.getElementById( 'ui' ); + const gui = options.gui || new GUI( { title: 'Mirrored Camera Culling' } ); + if ( ! options.gui && uiContainer ) uiContainer.appendChild( gui.domElement ); + + // Array Camera Controls + const arrayFolder = gui.addFolder( 'Array Camera' ); + arrayFolder.add( params, 'arrayCameraEnabled' ).name( 'Enable Array' ).onChange( toggleArrayCameraMode ); + arrayFolder.add( params, 'flipMode', [ 'all', 'odd', 'even', 'mod3' ] ).name( 'Flip Mode' ).onChange( () => { + if ( params.arrayCameraEnabled ) updateMirroredCameraArray(); + }); + const projFolder = gui.addFolder( 'Projection Flip' ); + projFolder.add( params.projection, 'x' ).name( 'X' ).onChange( () => { + applyProjectionState( mirroredCamera ); + if ( params.arrayCameraEnabled ) updateMirroredCameraArray(); + }); + projFolder.add( params.projection, 'y' ).name( 'Y' ).onChange( () => { + applyProjectionState( mirroredCamera ); + if ( params.arrayCameraEnabled ) updateMirroredCameraArray(); + }); + projFolder.add( params.projection, 'z' ).name( 'Z' ).onChange( () => { + applyProjectionState( mirroredCamera ); + if ( params.arrayCameraEnabled ) updateMirroredCameraArray(); + }); + + const viewFolder = gui.addFolder( 'View Scale Flip' ); + viewFolder.add( params.view, 'x' ).name( 'X' ).onChange( () => { + applyViewState( mirroredCamera ); + if ( params.arrayCameraEnabled ) updateMirroredCameraArray(); + }); + viewFolder.add( params.view, 'y' ).name( 'Y' ).onChange( () => { + applyViewState( mirroredCamera ); + if ( params.arrayCameraEnabled ) updateMirroredCameraArray(); + }); + viewFolder.add( params.view, 'z' ).name( 'Z' ).onChange( () => { + applyViewState( mirroredCamera ); + if ( params.arrayCameraEnabled ) updateMirroredCameraArray(); + }); + + const sceneFolder = gui.addFolder( 'Scene Flip' ); + sceneFolder.add( params.scene, 'x' ).name( 'X' ).onChange( applySceneState ); + sceneFolder.add( params.scene, 'y' ).name( 'Y' ).onChange( applySceneState ); + sceneFolder.add( params.scene, 'z' ).name( 'Z' ).onChange( applySceneState ); + + const cameraFolder = gui.addFolder( 'Camera' ); + cameraFolder.add( params.camera, 'type', [ 'Perspective', 'Orthographic' ] ).name( 'Type' ).onChange( rebuildCameras ); + cameraFolder.add( params.camera, 'fov', 10, 120, 1 ).name( 'Perspective FOV' ).onChange( () => { + if ( mainCamera.isPerspectiveCamera ) { mainCamera.fov = params.camera.fov; mainCamera.updateProjectionMatrix(); } + if ( mirroredCamera.isPerspectiveCamera ) { mirroredCamera.fov = params.camera.fov; mirroredCamera.updateProjectionMatrix(); applyProjectionState( mirroredCamera ); } + } ); + cameraFolder.add( params.camera, 'orthoSize', 1, 50, 0.1 ).name( 'Ortho Size' ).onChange( () => { + if ( mainCamera.isOrthographicCamera || mirroredCamera.isOrthographicCamera ) { resizeCameras(); } + } ); + + // Debug info under GUI + const debugState = { + main: { mwDet: '', pmDet: '', scale: '', worldScale: '', projDiag: '' }, + mirrored: { mwDet: '', pmDet: '', scale: '', worldScale: '', projDiag: '' }, + scene: { mwDet: '', scale: '' } + }; + const debugFolder = gui.addFolder( 'Debug' ); + const mainCamFolder = debugFolder.addFolder( 'Main Camera' ); + mainCamFolder.add( debugState.main, 'mwDet' ).name( 'matrixWorld.det' ).listen(); + mainCamFolder.add( debugState.main, 'pmDet' ).name( 'projection.det' ).listen(); + mainCamFolder.add( debugState.main, 'scale' ).name( 'scale' ).listen(); + mainCamFolder.add( debugState.main, 'worldScale' ).name( 'world scale' ).listen(); + mainCamFolder.add( debugState.main, 'projDiag' ).name( 'proj diag [m00,m11,m22]' ).listen(); + + const mirroredCamFolder = debugFolder.addFolder( 'Mirrored Camera' ); + mirroredCamFolder.add( debugState.mirrored, 'mwDet' ).name( 'matrixWorld.det' ).listen(); + mirroredCamFolder.add( debugState.mirrored, 'pmDet' ).name( 'projection.det' ).listen(); + mirroredCamFolder.add( debugState.mirrored, 'scale' ).name( 'scale' ).listen(); + mirroredCamFolder.add( debugState.mirrored, 'worldScale' ).name( 'world scale' ).listen(); + mirroredCamFolder.add( debugState.mirrored, 'projDiag' ).name( 'proj diag [m00,m11,m22]' ).listen(); + + const sceneInfoFolder = debugFolder.addFolder( 'Scene' ); + sceneInfoFolder.add( debugState.scene, 'mwDet' ).name( 'matrixWorld.det' ).listen(); + sceneInfoFolder.add( debugState.scene, 'scale' ).name( 'scale' ).listen(); + + projFolder.open(); + viewFolder.open(); + + // scene content + async function setupScene() { + + const step = 4; + + // primitives group (inlined from createPrimitivesGroup) + const group = new THREE.Group(); + const material = new THREE.MeshStandardMaterial( { color: 0x00ff00 } ); + + const helmetPath = 'https://cdn.jsdelivr.net/gh/mrdoob/three.js@dev/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf'; + const gltf = await gltfLoader.loadAsync( helmetPath ); + const helmet = gltf.scene; + helmet.scale.set( 1.5, 1.5, 1.5 ); + helmet.position.set( - step, 0, - step ); + group.add( helmet ); + + const coneGeo = new THREE.ConeGeometry( 1, 2, 32 ); + const cone = new THREE.Mesh( coneGeo, material ); + cone.position.set( 0, 1, - step ); + group.add( cone ); + + const torusGeo = new THREE.TorusGeometry( 1, 0.4, 16, 100 ); + const torus = new THREE.Mesh( torusGeo, material ); + torus.position.set( step, 1, - step ); + torus.rotation.x = Math.PI / 2; + group.add( torus ); + + const normalMaterial = new THREE.MeshNormalMaterial(); + const cube = new THREE.Mesh( new THREE.BoxGeometry( 1, 1, 1 ), normalMaterial ); + cube.position.set( - step, 0, 0 ); + group.add( cube ); + + group.position.x = 0; + scene.add( group ); + + // Add open geometry test mesh. Use TSL when running with WebGPU; fallback shader tweak for WebGL. + let testGeometry = new THREE.SphereGeometry( 2, 32, 32 ); + testGeometry = openUpGeometry( testGeometry ); + + let testMaterial; + if ( renderer.isWebGPURenderer && options.tsl ) { + + const { color, select, frontFacing, float } = options.tsl; + testMaterial = new THREE.MeshStandardNodeMaterial( { metalness: 0, roughness: 0.45 } ); + testMaterial.side = THREE.DoubleSide; + testMaterial.transparent = true; + testMaterial.depthWrite = false; + testMaterial.colorNode = select( frontFacing, color( 0xff0000 ), color( 0x0000ff ) ); + testMaterial.opacityNode = float( 0.6 ); + + const mesh = new THREE.Mesh( testGeometry, testMaterial ); + mesh.position.set( step, 0, 0 ); + scene.add( mesh ); + + } else { + + // WebGL fallback: use two meshes, one FrontSide (red), one BackSide (blue), drawn in order + const frontMat = new THREE.MeshStandardMaterial( { color: 0xff0000, metalness: 0, roughness: 0.45, side: THREE.FrontSide, transparent: true, opacity: 0.6 } ); + frontMat.depthWrite = false; + const backMat = new THREE.MeshStandardMaterial( { color: 0x0000ff, metalness: 0, roughness: 0.45, side: THREE.BackSide, transparent: true, opacity: 0.6 } ); + backMat.depthWrite = false; + + const backMesh = new THREE.Mesh( testGeometry, backMat ); + const frontMesh = new THREE.Mesh( testGeometry, frontMat ); + backMesh.position.set( step, 0, 0 ); + frontMesh.position.set( step, 0, 0 ); + backMesh.renderOrder = 0; + frontMesh.renderOrder = 1; + scene.add( backMesh ); + scene.add( frontMesh ); + + } + } + + function formatVec3( v ) { + return `(${v.x.toFixed( 3 )}, ${v.y.toFixed( 3 )}, ${v.z.toFixed( 3 )})`; + } + + const _pos = new THREE.Vector3(); + const _rot = new THREE.Quaternion(); + const _scl = new THREE.Vector3(); + + function getWorldScale( obj ) { + + obj.matrixWorld.decompose( _pos, _rot, _scl ); + return _scl.clone(); + + } + + function projDiag( cam ) { + + const e = cam.projectionMatrix.elements; + return new THREE.Vector3( e[ 0 ], e[ 5 ], e[ 10 ] ); + + } + + function updateDebug() { + + const cams = [ mainCamera, mirroredCamera ]; + for ( const cam of cams ) { + const mDet = cam.matrixWorld.determinant(); + const pDet = cam.projectionMatrix.determinant(); + const worldScale = getWorldScale( cam ); + const diag = projDiag( cam ); + const target = cam === mainCamera ? debugState.main : debugState.mirrored; + target.mwDet = mDet.toFixed( 6 ); + target.pmDet = pDet.toFixed( 6 ); + target.scale = formatVec3( cam.scale ); + target.worldScale = formatVec3( worldScale ); + target.projDiag = formatVec3( diag ); + } + + // scene info + scene.updateMatrixWorld( true ); + const sceneDet = scene.matrixWorld.determinant(); + debugState.scene.mwDet = sceneDet.toFixed( 6 ); + debugState.scene.scale = formatVec3( scene.scale ); + + } + + await setupScene(); + // apply initial state (mirror X on projection) + applyProjectionState( mirroredCamera ); +applySceneState(); + resizeCameras(); + + function animate() { + + requestAnimationFrame( animate ); + + const w = window.innerWidth; + const h = window.innerHeight; + const halfW = w / 2; + + // enable one set of controls based on mouse position + if ( mouseX < halfW ) { + mainControls.enabled = true; mirroredControls.enabled = false; + } else { + mainControls.enabled = false; mirroredControls.enabled = true; + } + + mainControls.update(); + mirroredControls.update(); + + renderer.setScissorTest( true ); + renderer.setScissor( 0, 0, halfW, h ); + renderer.setViewport( 0, 0, halfW, h ); + renderer.render( scene, mainCamera ); + + // Render right side - either single camera or array + if ( params.arrayCameraEnabled && mirroredCameraArray ) { + renderer.setScissor( halfW, 0, halfW, h ); + renderer.setViewport( halfW, 0, halfW, h ); + renderer.render( scene, mirroredCameraArray ); + } else { + renderer.setScissor( halfW, 0, halfW, h ); + renderer.setViewport( halfW, 0, halfW, h ); + renderer.render( scene, mirroredCamera ); + } + + renderer.setScissorTest( false ); + + updateDebug(); + + } + + animate(); + + window.addEventListener( 'resize', () => { + + resizeCameras(); + + } ); + + return { scene, mainCamera, mirroredCamera, renderer }; + +} diff --git a/examples/mirrored_camera_culling.html b/examples/mirrored_camera_culling.html new file mode 100644 index 00000000000000..bd6fba704122b9 --- /dev/null +++ b/examples/mirrored_camera_culling.html @@ -0,0 +1,33 @@ + + + + + + Mirrored Camera Culling Test + + + + + +
+ + + + + diff --git a/examples/mirrored_camera_culling_webgpu.html b/examples/mirrored_camera_culling_webgpu.html new file mode 100644 index 00000000000000..a3ab1b46471605 --- /dev/null +++ b/examples/mirrored_camera_culling_webgpu.html @@ -0,0 +1,53 @@ + + + + + + Mirrored Camera Culling Test (WebGPU) + + + + + +
+ + + + + diff --git a/src/renderers/WebGLRenderer.js b/src/renderers/WebGLRenderer.js index 8bdfba89d6359c..877b9ae0d462e7 100644 --- a/src/renderers/WebGLRenderer.js +++ b/src/renderers/WebGLRenderer.js @@ -1096,7 +1096,10 @@ class WebGLRenderer { if ( scene === null ) scene = _emptyScene; // renderBufferDirect second parameter used to be fog (could be null) - const frontFaceCW = ( object.isMesh && object.matrixWorld.determinant() < 0 ); + const objectFlipped = object.matrixWorld.determinant() < 0; + const viewFlipped = camera.matrixWorld.determinant() < 0; + const projectionFlipped = camera.projectionMatrix.determinant() > 0; // A standard projection's determinant is negative; a positive determinant will flip face culling + const frontFaceCW = object.isMesh ? ( objectFlipped ^ viewFlipped ^ projectionFlipped ) : false; const program = setProgram( camera, scene, geometry, material, object ); diff --git a/src/renderers/webgl-fallback/WebGLBackend.js b/src/renderers/webgl-fallback/WebGLBackend.js index 8cb2ef05c238e9..10c37ae63a8316 100644 --- a/src/renderers/webgl-fallback/WebGLBackend.js +++ b/src/renderers/webgl-fallback/WebGLBackend.js @@ -969,7 +969,17 @@ class WebGLBackend extends Backend { this._bindUniforms( renderObject.getBindings() ); - const frontFaceCW = ( object.isMesh && object.matrixWorld.determinant() < 0 ); + const computeFrontFaceCW = ( cam ) => { + + if ( object.isMesh !== true ) return false; + const objectFlipped = object.matrixWorld.determinant() < 0; + const viewFlipped = cam.matrixWorld.determinant() < 0; + const projectionFlipped = cam.projectionMatrix.determinant() > 0; // A standard projection's determinant is negative; a positive determinant will flip face culling + return ( ( objectFlipped ^ viewFlipped ^ projectionFlipped ) !== 0 ); + + }; + + let frontFaceCW = computeFrontFaceCW( renderObject.camera ); state.setMaterial( material, frontFaceCW, hardwareClippingPlanes ); @@ -1208,6 +1218,10 @@ class WebGLBackend extends Backend { state.bindBufferBase( gl.UNIFORM_BUFFER, cameraIndexData.index, cameraData.indexesGPU[ i ] ); + // Recompute and set front-face orientation for this sub-camera + frontFaceCW = computeFrontFaceCW( subCamera ); + state.setMaterial( material, frontFaceCW, hardwareClippingPlanes ); + draw(); } diff --git a/src/renderers/webgpu/WebGPUBackend.js b/src/renderers/webgpu/WebGPUBackend.js index 230fe6ee7b27a8..ce2f2da1fe1c56 100644 --- a/src/renderers/webgpu/WebGPUBackend.js +++ b/src/renderers/webgpu/WebGPUBackend.js @@ -1444,10 +1444,19 @@ class WebGPUBackend extends Backend { */ draw( renderObject, info ) { - const { object, material, context, pipeline } = renderObject; + const { object, material, context } = renderObject; const bindings = renderObject.getBindings(); const renderContextData = this.get( context ); - const pipelineGPU = this.get( pipeline ).pipeline; + + const computeFrontFaceCW = ( cam ) => { + + if ( object.isMesh !== true ) return false; + const objectFlipped = object.matrixWorld.determinant() < 0; + const viewFlipped = cam.matrixWorld.determinant() < 0; + const projectionFlipped = cam.projectionMatrix.determinant() > 0; // A standard projection's determinant is negative; a positive determinant will flip face culling + return ( ( objectFlipped ^ viewFlipped ^ projectionFlipped ) !== 0 ); + + }; const index = renderObject.getIndex(); const hasIndex = ( index !== null ); @@ -1458,9 +1467,10 @@ class WebGPUBackend extends Backend { // pipeline - const setPipelineAndBindings = ( passEncoderGPU, currentSets ) => { + const setPipelineAndBindings = ( passEncoderGPU, currentSets, frontFaceCW ) => { - // pipeline + // oriented pipeline (select orientation variant lazily) + const pipelineGPU = this.pipelineUtils.getRenderPipelineVariant( renderObject, frontFaceCW === true ); this.pipelineUtils.setPipeline( passEncoderGPU, pipelineGPU ); currentSets.pipeline = pipelineGPU; @@ -1528,9 +1538,9 @@ class WebGPUBackend extends Backend { }; // Define draw function - const draw = ( passEncoderGPU, currentSets ) => { + const draw = ( passEncoderGPU, currentSets, frontFaceCW ) => { - setPipelineAndBindings( passEncoderGPU, currentSets ); + setPipelineAndBindings( passEncoderGPU, currentSets, frontFaceCW ); if ( object.isBatchedMesh === true ) { @@ -1683,7 +1693,8 @@ class WebGPUBackend extends Backend { } - draw( pass, sets ); + const frontFaceCW = computeFrontFaceCW( subCamera ); + draw( pass, sets, frontFaceCW ); } @@ -1721,7 +1732,8 @@ class WebGPUBackend extends Backend { } - draw( renderContextData.currentPass, renderContextData.currentSets ); + const frontFaceCW = computeFrontFaceCW( renderObject.camera ); + draw( renderContextData.currentPass, renderContextData.currentSets, frontFaceCW ); } diff --git a/src/renderers/webgpu/utils/WebGPUPipelineUtils.js b/src/renderers/webgpu/utils/WebGPUPipelineUtils.js index 98db779127924d..1ef9a110ca2e61 100644 --- a/src/renderers/webgpu/utils/WebGPUPipelineUtils.js +++ b/src/renderers/webgpu/utils/WebGPUPipelineUtils.js @@ -81,12 +81,15 @@ class WebGPUPipelineUtils { } /** - * Creates a render pipeline for the given render object. + * Builds a GPURenderPipelineDescriptor for the given render object. * + * @private * @param {RenderObject} renderObject - The render object. - * @param {Array} promises - An array of compilation promises which are used in `compileAsync()`. + * @param {Object} [options={}] - Optional configuration. + * @param {?boolean} [options.frontFaceCW=false] - Controls the primitive front-face orientation; defaults to CCW. + * @return {Object} The render pipeline descriptor ready for createRenderPipeline. */ - createRenderPipeline( renderObject, promises ) { + _buildRenderPipelineDescriptor( renderObject, { frontFaceCW = false } = {} ) { const { object, material, geometry, pipeline } = renderObject; const { vertexProgram, fragmentProgram } = pipeline; @@ -95,7 +98,6 @@ class WebGPUPipelineUtils { const device = backend.device; const utils = backend.utils; - const pipelineData = backend.get( pipeline ); // bind group layouts @@ -173,14 +175,15 @@ class WebGPUPipelineUtils { const vertexModule = backend.get( vertexProgram ).module; const fragmentModule = backend.get( fragmentProgram ).module; - const primitiveState = this._getPrimitiveState( object, geometry, material ); + const primitiveState = this._getPrimitiveState( object, geometry, material, frontFaceCW ); + const depthCompare = this._getDepthCompare( material ); const depthStencilFormat = utils.getCurrentDepthStencilFormat( renderObject.context ); const sampleCount = this._getSampleCount( renderObject.context ); const pipelineDescriptor = { - label: `renderPipeline_${ material.name || material.type }_${ material.id }`, + label: `renderPipeline_${ material.name || material.type }_${ material.id }${ frontFaceCW ? '_CW' : '' }`, vertex: Object.assign( {}, vertexModule, { buffers: vertexBuffers } ), fragment: Object.assign( {}, fragmentModule, { targets } ), primitive: primitiveState, @@ -229,6 +232,26 @@ class WebGPUPipelineUtils { } + return pipelineDescriptor; + + } + + /** + * Creates a render pipeline for the given render object. + * + * @param {RenderObject} renderObject - The render object. + * @param {Array} promises - An array of compilation promises which are used in `compileAsync()`. + */ + createRenderPipeline( renderObject, promises ) { + + const { pipeline } = renderObject; + + const backend = this.backend; + const device = backend.device; + + const pipelineData = backend.get( pipeline ); + + const pipelineDescriptor = this._buildRenderPipelineDescriptor( renderObject ); if ( promises === null ) { @@ -253,6 +276,54 @@ class WebGPUPipelineUtils { } + /** + * Returns a render pipeline variant with the requested front-face orientation. + * If necessary, a new pipeline is created lazily and cached. + * + * @param {RenderObject} renderObject - The render object. + * @param {boolean} frontFaceCW - Whether the front face should be CW (true) or CCW (false). + * @return {GPURenderPipeline} The pipeline for the requested orientation. + */ + getRenderPipelineVariant( renderObject, frontFaceCW ) { + + const { material, pipeline } = renderObject; + + const backend = this.backend; + const device = backend.device; + + const pipelineData = backend.get( pipeline ); + + console.assert( pipelineData.pipeline !== undefined, 'Pipeline not created' ); + + // DoubleSide does not cull, orientation is irrelevant + if ( material.side === DoubleSide ) { + + return pipelineData.pipeline; + + } + + // Default CCW pipeline already handled by createRenderPipeline() + if ( frontFaceCW === false ) { + + return pipelineData.pipeline; + + } + + // Need CW variant + if ( pipelineData.pipelineCW !== undefined ) { + + return pipelineData.pipelineCW; + + } + + // Build a CW-oriented pipeline by reusing the shared descriptor with frontFace override + const pipelineDescriptor = this._buildRenderPipelineDescriptor( renderObject, { frontFaceCW: true } ); + pipelineData.pipelineCW = device.createRenderPipeline( pipelineDescriptor ); + + return pipelineData.pipelineCW; + + } + /** * Creates GPU render bundle encoder for the given render context. * @@ -665,9 +736,10 @@ class WebGPUPipelineUtils { * @param {Object3D} object - The 3D object. * @param {BufferGeometry} geometry - The geometry. * @param {Material} material - The material. + * @param {?boolean} [frontFaceCW=false] - Primitive front-face orientation; false=CCW (default), true=CW. * @return {Object} The primitive state. */ - _getPrimitiveState( object, geometry, material ) { + _getPrimitiveState( object, geometry, material, frontFaceCW = false ) { const descriptor = {}; const utils = this.backend.utils; @@ -684,9 +756,11 @@ class WebGPUPipelineUtils { // + // Handle flip sided based on material side and object transformation let flipSided = ( material.side === BackSide ); - if ( object.isMesh && object.matrixWorld.determinant() < 0 ) flipSided = ! flipSided; + // Apply frontFaceCW parameter if specified + if ( frontFaceCW ) flipSided = ! flipSided; descriptor.frontFace = ( flipSided === true ) ? GPUFrontFace.CW : GPUFrontFace.CCW;