UNPKG

three

Version:

JavaScript 3D library

794 lines (512 loc) • 23.1 kB
import { Box3, Color, MathUtils, Matrix4, Vector2, Vector3, BackSide, DoubleSide, FrontSide, SRGBColorSpace } from 'three/webgpu'; const _matrix = /*@__PURE__*/ new Matrix4(); const _box = /*@__PURE__*/ new Box3(); const _va = /*@__PURE__*/ new Vector3(); const _vb = /*@__PURE__*/ new Vector3(); const _vc = /*@__PURE__*/ new Vector3(); const _uva = /*@__PURE__*/ new Vector2(); const _uvb = /*@__PURE__*/ new Vector2(); const _uvc = /*@__PURE__*/ new Vector2(); const _uv = /*@__PURE__*/ new Vector2(); const _color = /*@__PURE__*/ new Color(); const _albedo = /*@__PURE__*/ new Color(); const _emissive = /*@__PURE__*/ new Color(); const _columns = /*@__PURE__*/ new Map(); const IMAGE_SAMPLE_SIZE = 64; const POSITION_QUANTIZATION = 131072; /** * Number of floats per triangle record: three positions (vec4 each, w unused), * albedo (rgb + side flag) and emissive (rgb + unused). */ export const TRIANGLE_STRIDE = 20; const _srgbLUT = new Float32Array( 256 ); for ( let i = 0; i < 256; i ++ ) { const c = i / 255; _srgbLUT[ i ] = ( c < 0.04045 ) ? c * 0.0773993808 : Math.pow( c * 0.9478672986 + 0.0521327014, 2.4 ); } class GrowableFloat32Array { constructor( initialSize ) { this.array = new Float32Array( initialSize ); this.length = 0; } ensure( count ) { if ( this.length + count > this.array.length ) { let size = this.array.length * 2; while ( size < this.length + count ) size *= 2; const array = new Float32Array( size ); array.set( this.array ); this.array = array; } } push4( x, y, z, w ) { const a = this.array, i = this.length; a[ i ] = x; a[ i + 1 ] = y; a[ i + 2 ] = z; a[ i + 3 ] = w; this.length += 4; } result() { return this.array.slice( 0, this.length ); } } /** * Downscales a texture image into a small pixel buffer so triangle colors can be * looked up on the CPU. Returns `null` for images that cannot be read. * * @private * @param {Texture} texture - The texture. * @param {Map<string, ?Object>} cache - Cache keyed by texture uuid. * @return {?{data: Uint8ClampedArray, width: number, height: number, srgb: boolean}} The image sampler. */ function getImageSampler( texture, cache ) { if ( cache.has( texture.uuid ) ) return cache.get( texture.uuid ); let sampler = null; const image = texture.image; if ( image && texture.isCompressedTexture !== true ) { const srgb = texture.colorSpace === SRGBColorSpace; if ( image.data !== undefined && image.width !== undefined ) { // DataTexture (RGBA 8-bit only) if ( image.data.length === image.width * image.height * 4 && image.data.BYTES_PER_ELEMENT === 1 ) { sampler = { data: image.data, width: image.width, height: image.height, srgb }; } } else if ( image.width > 0 && image.height > 0 ) { try { const size = IMAGE_SAMPLE_SIZE; const canvas = ( typeof OffscreenCanvas !== 'undefined' ) ? new OffscreenCanvas( size, size ) : document.createElement( 'canvas' ); canvas.width = size; canvas.height = size; const context = canvas.getContext( '2d', { willReadFrequently: true } ); context.imageSmoothingEnabled = true; context.imageSmoothingQuality = 'high'; context.drawImage( image, 0, 0, size, size ); sampler = { data: context.getImageData( 0, 0, size, size ).data, width: size, height: size, srgb }; } catch ( e ) { sampler = null; } } } cache.set( texture.uuid, sampler ); return sampler; } /** * Samples the given texture at the given uv coordinates. The result is written * into `target` (linear rgb) and the alpha is returned. * * @private */ function sampleTexture( texture, sampler, uv, target ) { _uv.copy( uv ); texture.transformUv( _uv ); const x = Math.min( sampler.width - 1, Math.max( 0, Math.floor( _uv.x * sampler.width ) ) ); const y = Math.min( sampler.height - 1, Math.max( 0, Math.floor( _uv.y * sampler.height ) ) ); const i = ( y * sampler.width + x ) * 4; const data = sampler.data; if ( sampler.srgb ) { target.setRGB( _srgbLUT[ data[ i ] ], _srgbLUT[ data[ i + 1 ] ], _srgbLUT[ data[ i + 2 ] ] ); } else { target.setRGB( data[ i ] / 255, data[ i + 1 ] / 255, data[ i + 2 ] / 255 ); } return data[ i + 3 ] / 255; } function getMaterialInfo( material, cache, imageCache ) { if ( cache.has( material ) ) return cache.get( material ); const unlit = material.isMeshBasicMaterial === true || material.isMeshBasicNodeMaterial === true; const info = { unlit, side: material.side === BackSide ? 1 : ( material.side === DoubleSide ? 2 : 0 ), opacity: material.transparent === true ? material.opacity : 1, alphaTest: material.alphaTest || 0, color: material.color ? material.color : null, emissive: ( ! unlit && material.emissive ) ? material.emissive : null, emissiveIntensity: material.emissiveIntensity !== undefined ? material.emissiveIntensity : 1, map: null, mapSampler: null, emissiveMap: null, emissiveMapSampler: null }; if ( material.map && material.map.isTexture ) { info.mapSampler = getImageSampler( material.map, imageCache ); if ( info.mapSampler !== null ) info.map = material.map; } if ( ! unlit && material.emissiveMap && material.emissiveMap.isTexture ) { info.emissiveMapSampler = getImageSampler( material.emissiveMap, imageCache ); if ( info.emissiveMapSampler !== null ) info.emissiveMap = material.emissiveMap; } // the uv transform is normally updated by the renderer if ( info.map !== null && info.map.matrixAutoUpdate === true ) info.map.updateMatrix(); if ( info.emissiveMap !== null && info.emissiveMap.matrixAutoUpdate === true ) info.emissiveMap.updateMatrix(); cache.set( material, info ); return info; } /** * Returns `true` if the object should contribute geometry to the voxel volume. * * @private */ function isVoxelizable( object, layers, exclude ) { return object.isMesh === true && object.isSkyMesh !== true && object.isBatchedMesh !== true && layers.test( object.layers ) === true && exclude.has( object ) === false && object.geometry !== undefined; } /** * Computes the world-space bounding box of all voxelizable meshes. * * @param {Scene} scene - The scene. * @param {Layers} layers - Only objects that pass the layer test are considered. * @param {Set<Object3D>} exclude - Objects to skip. * @param {Box3} target - The target box. * @return {Box3} The bounding box. */ export function computeSceneBounds( scene, layers, exclude, target ) { target.makeEmpty(); scene.traverseVisible( ( object ) => { if ( isVoxelizable( object, layers, exclude ) ) { target.expandByObject( object, true ); } } ); return target; } /** * Collects the triangles of all meshes in the scene as flat, world-space records suitable * for GPU voxelization. Per-triangle albedo and emissive colors are resolved on the CPU * (material color multiplied with a texture lookup at the triangle's centroid). Large * triangles are subdivided so every record covers a bounded number of voxels. * * @param {Scene} scene - The scene. * @param {Object} options - Options. * @param {Box3} options.bounds - Triangles outside these bounds are skipped. * @param {Layers} options.layers - Objects must pass this layer test. * @param {Set<Object3D>} options.exclude - Objects to skip. * @param {number} options.subVoxelSize - Size of a sub-voxel in world units. * @param {number} options.maxEdge - Maximum triangle edge length in sub-voxels before subdivision. * @param {number} options.minOpacity - Triangles with lower opacity are skipped. * @return {{data: Float32Array, count: number}} The triangle records and triangle count. */ export function collectSceneTriangles( scene, options ) { const { bounds, layers, exclude, subVoxelSize, maxEdge, minOpacity } = options; const materialCache = new Map(); const imageCache = new Map(); const buffer = new GrowableFloat32Array( 1024 * TRIANGLE_STRIDE ); const maxEdgeSq = ( maxEdge * subVoxelSize ) * ( maxEdge * subVoxelSize ); // triangle emission with subdivision of large triangles const stack = []; function emit( a, b, c, albedo, emissive, side ) { stack.push( a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z ); while ( stack.length > 0 ) { const cz = stack.pop(), cy = stack.pop(), cx = stack.pop(); const bz = stack.pop(), by = stack.pop(), bx = stack.pop(); const az = stack.pop(), ay = stack.pop(), ax = stack.pop(); const abSq = ( bx - ax ) ** 2 + ( by - ay ) ** 2 + ( bz - az ) ** 2; const bcSq = ( cx - bx ) ** 2 + ( cy - by ) ** 2 + ( cz - bz ) ** 2; const caSq = ( ax - cx ) ** 2 + ( ay - cy ) ** 2 + ( az - cz ) ** 2; const longest = Math.max( abSq, bcSq, caSq ); if ( longest > maxEdgeSq ) { // split along the longest edge if ( longest === abSq ) { const mx = ( ax + bx ) * 0.5, my = ( ay + by ) * 0.5, mz = ( az + bz ) * 0.5; stack.push( ax, ay, az, mx, my, mz, cx, cy, cz ); stack.push( mx, my, mz, bx, by, bz, cx, cy, cz ); } else if ( longest === bcSq ) { const mx = ( bx + cx ) * 0.5, my = ( by + cy ) * 0.5, mz = ( bz + cz ) * 0.5; stack.push( ax, ay, az, bx, by, bz, mx, my, mz ); stack.push( ax, ay, az, mx, my, mz, cx, cy, cz ); } else { const mx = ( cx + ax ) * 0.5, my = ( cy + ay ) * 0.5, mz = ( cz + az ) * 0.5; stack.push( ax, ay, az, bx, by, bz, mx, my, mz ); stack.push( mx, my, mz, bx, by, bz, cx, cy, cz ); } continue; } buffer.ensure( TRIANGLE_STRIDE ); buffer.push4( ax, ay, az, 0 ); buffer.push4( bx, by, bz, 0 ); buffer.push4( cx, cy, cz, 0 ); buffer.push4( albedo.r, albedo.g, albedo.b, side ); buffer.push4( emissive.r, emissive.g, emissive.b, 0 ); } } function processMesh( mesh, matrixWorld ) { const geometry = mesh.geometry; const positionAttribute = geometry.getAttribute( 'position' ); if ( positionAttribute === undefined ) return; const index = geometry.index; const materials = Array.isArray( mesh.material ) ? mesh.material : [ mesh.material ]; const vertexCount = index !== null ? index.count : positionAttribute.count; let groups = geometry.groups; if ( Array.isArray( mesh.material ) === false || groups.length === 0 ) { groups = [ { start: 0, count: vertexCount, materialIndex: 0 } ]; } const drawRange = geometry.drawRange; const rangeStart = drawRange.start; const rangeEnd = Math.min( vertexCount, drawRange.start + drawRange.count ); for ( const group of groups ) { const material = materials[ group.materialIndex ]; if ( material === undefined || material === null || material.visible === false ) continue; const info = getMaterialInfo( material, materialCache, imageCache ); if ( info.opacity < minOpacity ) continue; const uvAttribute = info.map !== null ? geometry.getAttribute( info.map.channel > 0 ? 'uv' + info.map.channel : 'uv' ) : null; const emissiveUVAttribute = info.emissiveMap !== null ? geometry.getAttribute( info.emissiveMap.channel > 0 ? 'uv' + info.emissiveMap.channel : 'uv' ) : null; const start = Math.max( group.start, rangeStart ); const end = Math.min( group.start + group.count, rangeEnd ); for ( let i = start; i + 2 < end; i += 3 ) { const ia = index !== null ? index.getX( i ) : i; const ib = index !== null ? index.getX( i + 1 ) : i + 1; const ic = index !== null ? index.getX( i + 2 ) : i + 2; _va.fromBufferAttribute( positionAttribute, ia ).applyMatrix4( matrixWorld ); _vb.fromBufferAttribute( positionAttribute, ib ).applyMatrix4( matrixWorld ); _vc.fromBufferAttribute( positionAttribute, ic ).applyMatrix4( matrixWorld ); // skip triangles outside the volume _box.makeEmpty().expandByPoint( _va ).expandByPoint( _vb ).expandByPoint( _vc ); if ( bounds.intersectsBox( _box ) === false ) continue; // skip degenerate triangles if ( _box.min.distanceToSquared( _box.max ) < 1e-14 ) continue; // resolve colors let alpha = info.opacity; if ( info.color !== null ) _albedo.copy( info.color ); else _albedo.setRGB( 1, 1, 1 ); if ( uvAttribute !== undefined && uvAttribute !== null ) { _uva.fromBufferAttribute( uvAttribute, ia ); _uvb.fromBufferAttribute( uvAttribute, ib ); _uvc.fromBufferAttribute( uvAttribute, ic ); _uva.add( _uvb ).add( _uvc ).multiplyScalar( 1 / 3 ); alpha *= sampleTexture( info.map, info.mapSampler, _uva, _color ); _albedo.multiply( _color ); } if ( info.alphaTest > 0 && alpha < info.alphaTest ) continue; if ( alpha < minOpacity ) continue; if ( info.unlit ) { _emissive.copy( _albedo ); _albedo.setRGB( 0, 0, 0 ); } else { if ( info.emissive !== null ) { _emissive.copy( info.emissive ).multiplyScalar( info.emissiveIntensity ); if ( emissiveUVAttribute !== undefined && emissiveUVAttribute !== null ) { _uva.fromBufferAttribute( emissiveUVAttribute, ia ); _uvb.fromBufferAttribute( emissiveUVAttribute, ib ); _uvc.fromBufferAttribute( emissiveUVAttribute, ic ); _uva.add( _uvb ).add( _uvc ).multiplyScalar( 1 / 3 ); sampleTexture( info.emissiveMap, info.emissiveMapSampler, _uva, _color ); _emissive.multiply( _color ); } } else { _emissive.setRGB( 0, 0, 0 ); } } emit( _va, _vb, _vc, _albedo, _emissive, info.side ); } } } scene.traverseVisible( ( object ) => { if ( isVoxelizable( object, layers, exclude ) === false ) return; if ( object.isInstancedMesh === true ) { for ( let i = 0; i < object.count; i ++ ) { object.getMatrixAt( i, _matrix ); _matrix.premultiply( object.matrixWorld ); processMesh( object, _matrix ); } } else { processMesh( object, object.matrixWorld ); } } ); const data = buffer.result(); return { data, count: data.length / TRIANGLE_STRIDE }; } /** * Marks the sub-voxels inside closed meshes, so cones starting within an object are blocked. Only meshes * with visible, opaque and single-sided materials whose geometry is closed and faces outwards are filled. * * @param {Scene} scene - The scene. * @param {Object} options - Options. * @param {Vector3} options.boundsMin - The minimum corner of the voxel grid. * @param {number} options.voxelSize - The size of a voxel in world units. * @param {Vector3} options.gridSize - The number of voxels per axis. * @param {Layers} options.layers - Objects must pass this layer test. * @param {Set<Object3D>} options.exclude - Objects to skip. * @param {Uint8Array} target - One byte per voxel that receives the 2x2x2 sub-voxel bits of the interiors. */ export function computeSolidVoxels( scene, options, target ) { const { boundsMin, voxelSize, gridSize, layers, exclude } = options; const closedGeometries = new Map(); scene.traverseVisible( ( object ) => { if ( isVoxelizable( object, layers, exclude ) === false || hasSolidMaterials( object ) === false ) return; const geometry = object.geometry; if ( closedGeometries.has( geometry ) === false ) closedGeometries.set( geometry, isClosed( geometry ) && facesOutwards( geometry ) ); if ( closedGeometries.get( geometry ) === false ) return; if ( object.isInstancedMesh === true ) { for ( let i = 0; i < object.count; i ++ ) { object.getMatrixAt( i, _matrix ); _matrix.premultiply( object.matrixWorld ); fillInterior( geometry, _matrix, boundsMin, voxelSize, gridSize, target ); } } else { fillInterior( geometry, object.matrixWorld, boundsMin, voxelSize, gridSize, target ); } } ); } /** * Returns `true` if all materials of the mesh render an opaque, single-sided surface. * * @private */ function hasSolidMaterials( mesh ) { const materials = Array.isArray( mesh.material ) ? mesh.material : [ mesh.material ]; for ( const material of materials ) { if ( material.visible === false || material.side !== FrontSide || material.transparent === true || material.alphaTest > 0 ) return false; } return true; } /** * Returns `true` if every edge of the geometry is shared by an even number of triangles. Vertices with * the same position (e.g. at uv seams) are treated as one vertex. * * @private */ function isClosed( geometry ) { const position = geometry.getAttribute( 'position' ); if ( position === undefined ) return false; const index = geometry.index; const indices = index !== null ? index.array : null; const count = index !== null ? index.count : position.count; _box.setFromBufferAttribute( position ); const min = _box.min; const size = _box.getSize( _vb ); const scaleX = POSITION_QUANTIZATION / Math.max( size.x, 1e-9 ); const scaleY = POSITION_QUANTIZATION / Math.max( size.y, 1e-9 ); const scaleZ = POSITION_QUANTIZATION / Math.max( size.z, 1e-9 ); const vertexMask = MathUtils.ceilPowerOfTwo( Math.max( position.count, 1 ) * 2 ) - 1; const vertexKeys = new Int32Array( ( vertexMask + 1 ) * 3 ); const vertexSlots = new Int32Array( vertexMask + 1 ).fill( - 1 ); const vertexIds = new Int32Array( position.count ); let vertexCount = 0; for ( let i = 0; i < position.count; i ++ ) { const x = Math.round( ( position.getX( i ) - min.x ) * scaleX ); const y = Math.round( ( position.getY( i ) - min.y ) * scaleY ); const z = Math.round( ( position.getZ( i ) - min.z ) * scaleZ ); let slot = ( Math.imul( x, 73856093 ) ^ Math.imul( y, 19349663 ) ^ Math.imul( z, 83492791 ) ) & vertexMask; while ( vertexSlots[ slot ] !== - 1 && ( vertexKeys[ slot * 3 ] !== x || vertexKeys[ slot * 3 + 1 ] !== y || vertexKeys[ slot * 3 + 2 ] !== z ) ) slot = ( slot + 1 ) & vertexMask; if ( vertexSlots[ slot ] === - 1 ) { vertexSlots[ slot ] = vertexCount ++; vertexKeys[ slot * 3 ] = x; vertexKeys[ slot * 3 + 1 ] = y; vertexKeys[ slot * 3 + 2 ] = z; } vertexIds[ i ] = vertexSlots[ slot ]; } const edgeMask = MathUtils.ceilPowerOfTwo( Math.max( count, 1 ) * 2 ) - 1; const edgeKeys = new Int32Array( ( edgeMask + 1 ) * 2 ).fill( - 1 ); const edgeParity = new Uint8Array( edgeMask + 1 ); let openEdges = 0; const toggleEdge = ( a, b ) => { if ( a > b ) { const t = a; a = b; b = t; } let slot = ( Math.imul( a, 73856093 ) ^ Math.imul( b, 19349663 ) ) & edgeMask; while ( edgeKeys[ slot * 2 ] !== - 1 && ( edgeKeys[ slot * 2 ] !== a || edgeKeys[ slot * 2 + 1 ] !== b ) ) slot = ( slot + 1 ) & edgeMask; edgeKeys[ slot * 2 ] = a; edgeKeys[ slot * 2 + 1 ] = b; edgeParity[ slot ] ^= 1; openEdges += edgeParity[ slot ] === 1 ? 1 : - 1; }; for ( let i = 0; i + 2 < count; i += 3 ) { const a = vertexIds[ indices !== null ? indices[ i ] : i ]; const b = vertexIds[ indices !== null ? indices[ i + 1 ] : i + 1 ]; const c = vertexIds[ indices !== null ? indices[ i + 2 ] : i + 2 ]; if ( a === b || b === c || c === a ) continue; toggleEdge( a, b ); toggleEdge( b, c ); toggleEdge( c, a ); } return openEdges === 0; } /** * Returns `true` if the triangles of the given closed geometry face outwards (positive signed volume). * * @private */ function facesOutwards( geometry ) { const position = geometry.getAttribute( 'position' ); const index = geometry.index; const indices = index !== null ? index.array : null; const count = index !== null ? index.count : position.count; _box.min.fromBufferAttribute( position, 0 ); let volume = 0; for ( let i = 0; i + 2 < count; i += 3 ) { _va.fromBufferAttribute( position, indices !== null ? indices[ i ] : i ).sub( _box.min ); _vb.fromBufferAttribute( position, indices !== null ? indices[ i + 1 ] : i + 1 ).sub( _box.min ); _vc.fromBufferAttribute( position, indices !== null ? indices[ i + 2 ] : i + 2 ).sub( _box.min ); volume += _va.dot( _vb.cross( _vc ) ); } return volume > 0; } /** * Marks the sub-voxels between pairs of surface crossings along each sub-voxel column in x. * * @private */ function fillInterior( geometry, matrix, boundsMin, voxelSize, gridSize, target ) { const position = geometry.getAttribute( 'position' ); const index = geometry.index; const count = index !== null ? index.count : position.count; const invSubVoxel = 2 / voxelSize; const columnsY = gridSize.y * 2, columnsZ = gridSize.z * 2, length = gridSize.x * 2; _columns.clear(); for ( let i = 0; i + 2 < count; i += 3 ) { const a = _va.fromBufferAttribute( position, index !== null ? index.getX( i ) : i ).applyMatrix4( matrix ).sub( boundsMin ).multiplyScalar( invSubVoxel ); let b = _vb.fromBufferAttribute( position, index !== null ? index.getX( i + 1 ) : i + 1 ).applyMatrix4( matrix ).sub( boundsMin ).multiplyScalar( invSubVoxel ); let c = _vc.fromBufferAttribute( position, index !== null ? index.getX( i + 2 ) : i + 2 ).applyMatrix4( matrix ).sub( boundsMin ).multiplyScalar( invSubVoxel ); const area = edgeFunction( a, b, c.y, c.z ); if ( area === 0 ) continue; if ( area < 0 ) { const t = b; b = c; c = t; } const j0 = Math.max( Math.ceil( Math.min( a.y, b.y, c.y ) - 0.5 ), 0 ); const j1 = Math.min( Math.floor( Math.max( a.y, b.y, c.y ) - 0.5 ), columnsY - 1 ); const k0 = Math.max( Math.ceil( Math.min( a.z, b.z, c.z ) - 0.5 ), 0 ); const k1 = Math.min( Math.floor( Math.max( a.z, b.z, c.z ) - 0.5 ), columnsZ - 1 ); for ( let k = k0; k <= k1; k ++ ) { for ( let j = j0; j <= j1; j ++ ) { const y = j + 0.5, z = k + 0.5; const wa = edgeFunction( b, c, y, z ); const wb = edgeFunction( c, a, y, z ); const wc = edgeFunction( a, b, y, z ); if ( isInside( wa, b, c ) && isInside( wb, c, a ) && isInside( wc, a, b ) ) { const column = j + k * columnsY; let crossings = _columns.get( column ); if ( crossings === undefined ) { crossings = []; _columns.set( column, crossings ); } crossings.push( ( wa * a.x + wb * b.x + wc * c.x ) / ( wa + wb + wc ) ); } } } } for ( const [ column, crossings ] of _columns ) { if ( crossings.length % 2 !== 0 ) continue; crossings.sort( ( p, q ) => p - q ); const j = column % columnsY, k = ( column - j ) / columnsY; const offset = gridSize.x * ( ( j >> 1 ) + gridSize.y * ( k >> 1 ) ); const bitsYZ = ( ( j & 1 ) << 1 ) | ( ( k & 1 ) << 2 ); for ( let n = 0; n < crossings.length; n += 2 ) { const i0 = Math.max( Math.ceil( crossings[ n ] - 0.5 ), 0 ); const i1 = Math.min( Math.ceil( crossings[ n + 1 ] - 0.5 ), length ); for ( let i = i0; i < i1; i ++ ) target[ offset + ( i >> 1 ) ] |= 1 << ( ( i & 1 ) | bitsYZ ); } } } /** * Edge function of the edge from `a` to `b` at the given point in the yz plane. As in the watertight * ray/triangle test, the vertices are taken relative to the point, so a shared edge evaluates exactly * opposite for both of its triangles. * * @private */ function edgeFunction( a, b, y, z ) { return ( a.y - y ) * ( b.z - z ) - ( a.z - z ) * ( b.y - y ); } /** * Inside test for an edge function value. Points on an edge belong to one triangle only (top-left rule). * * @private */ function isInside( w, a, b ) { if ( w !== 0 ) return w > 0; const dz = b.z - a.z; return dz < 0 || ( dz === 0 && b.y > a.y ); }