UNPKG

three

Version:

JavaScript 3D library

480 lines (355 loc) 18.1 kB
import { BoxGeometry, BufferGeometry, CircleGeometry, Color, Float32BufferAttribute, Group, InstancedBufferAttribute, InterpolationSamplingMode, InterpolationSamplingType, LatheGeometry, Vector2, Vector3 } from 'three'; import { MeshStandardNodeMaterial } from 'three/webgpu'; import { atan, attribute, color, float, mix, normalGeometry, positionGeometry, select, smoothstep, uniform, uniformArray, uv, varying, vec2 } from 'three/tsl'; import { mergeGeometries } from '../../utils/BufferGeometryUtils.js'; import { LoftGeometry } from '../../geometries/LoftGeometry.js'; import { createInstances, updateInstances } from './InstancedMeshGenerator.js'; import { part } from './CityGeneratorUtils.js'; /** * A low-poly car fleet with lofted bodies, circular wheel arches, curved * windscreens and recessed alloy wheels. Separate cabin panels define the * windows and their frames. Two body types ( sedan and SUV ) are mixed * deterministically across the fleet, and the taxi colour gets its own sedan with * a roof sign, so a parked row reads as different vehicles rather than one mould. * * Each geometry is built once per type and shared; cars are grouped by body * type with per-instance paint, so each group is a single instanced draw that * assigns paint, glass, tyres and lamps using a baked `partId`, panel UVs and * canonical-space masks. * * The canonical model stands with its wheels on `y = 0`, centred in X / Z, facing * `+Z`, so a placement whose local `+Z` faces the road parks it nose-out. * * ```js * const cars = new CarGenerator(); * scene.add( cars.build( placements ) ); // placements: { matrix: Matrix4, color }[] * ``` */ class CarGenerator { constructor( parameters = {} ) { this.parameters = Object.assign( {}, CarGenerator.defaults, parameters ); this.geometries = new Map(); // one shared shell per body type this.materials = new Map(); // one material per body type this.mesh = null; } build( cars ) { if ( this.mesh && this.mesh.children.some( ( mesh ) => mesh.instanceMatrix.count < cars.length ) ) this.dispose(); // bucket the fleet by body type for one instanced draw per shell. // the taxi colour always gets the signed sedan; // the rest split deterministically between sedan and SUV const buckets = new Map(); for ( let i = 0; i < cars.length; i ++ ) { const car = cars[ i ]; const type = car.color === CarGenerator.taxiColor ? 'taxi' : ( ( ( i * 2654435761 ) >>> 0 ) % 100 < 42 ? 'suv' : 'sedan' ); if ( ! buckets.has( type ) ) buckets.set( type, [] ); buckets.get( type ).push( car ); } if ( this.mesh === null ) { this.mesh = new Group(); this.mesh.name = 'Cars'; } const group = this.mesh; for ( const mesh of group.children ) updateInstances( mesh, [] ); const paint = new Color(); for ( const [ type, instances ] of buckets ) { let geometry = this.geometries.get( type ); if ( geometry === undefined ) { geometry = buildCarGeometry( BODY_SPECS[ type ] ); this.geometries.set( type, geometry ); } let material = this.materials.get( type ); if ( material === undefined ) { material = createCarMaterial( BODY_SPECS[ type ] ); this.materials.set( type, material ); } let mesh = group.children.find( ( child ) => child.geometry === geometry ); if ( mesh === undefined ) { mesh = createInstances( geometry, material, cars.length, 'Car' ); geometry.setAttribute( 'paintColor', new InstancedBufferAttribute( new Float32Array( mesh.instanceMatrix.count * 3 ), 3 ) ); group.add( mesh ); } const colors = geometry.getAttribute( 'paintColor' ); for ( let i = 0; i < instances.length; i ++ ) { paint.set( instances[ i ].color ).toArray( colors.array, i * 3 ); } colors.needsUpdate = true; updateInstances( mesh, instances.map( ( car ) => car.matrix ) ); } return group; } dispose() { for ( const geometry of this.geometries.values() ) geometry.dispose(); this.geometries.clear(); if ( this.mesh ) this.mesh.traverse( ( object ) => object.dispose() ); for ( const material of this.materials.values() ) material.dispose(); this.materials.clear(); this.mesh = null; } } CarGenerator.defaults = {}; // the paint colour that gets the roof-signed taxi shell CarGenerator.taxiColor = 0xf5c518; const BODY = 0, WINDOW = 1, TYRE = 2, ALLOY = 3, TRIM = 4, MIRROR = 5, SIGN = 6, FRONT = 7, REAR = 8; // Body sections are [ z, half-width, shoulder height, deck height ]. The cabin // has separate base and roof corners, so changing its shape also moves its panes. const BODY_SPECS = { sedan: { body: [ [ 2.25, 0.79, 0.67, 0.79 ], [ 2.11, 0.89, 0.78, 0.91 ], [ 1.38, 0.94, 0.87, 1.00 ], [ 0.75, 0.92, 0.89, 1.025 ], [ - 0.45, 0.92, 0.91, 1.04 ], [ - 1.38, 0.94, 0.88, 1.06 ], [ - 2.10, 0.89, 0.77, 0.97 ], [ - 2.25, 0.81, 0.70, 0.85 ] ], front: { base: [ 0.82, 0.99, 0.78 ], roof: [ 0.69, 1.45, 0.12 ] }, rear: { base: [ 0.83, 1.02, - 1.15 ], roof: [ 0.71, 1.47, - 0.72 ] }, wheelRadius: 0.35, wheelZ: 1.38, wheelX: 0.83, pillars: [ - 0.30 ], lamps: [ 0.71, 0.76 ], sign: false, rails: false }, suv: { body: [ [ 2.30, 0.84, 0.84, 0.99 ], [ 2.15, 0.94, 0.94, 1.10 ], [ 1.40, 0.98, 1.02, 1.17 ], [ 0.76, 0.96, 1.04, 1.19 ], [ - 0.45, 0.96, 1.05, 1.20 ], [ - 1.40, 0.98, 1.03, 1.21 ], [ - 2.16, 0.94, 0.94, 1.16 ], [ - 2.30, 0.84, 0.86, 1.03 ] ], front: { base: [ 0.86, 1.15, 0.78 ], roof: [ 0.76, 1.73, 0.18 ] }, rear: { base: [ 0.87, 1.15, - 2.12 ], roof: [ 0.77, 1.75, - 1.66 ] }, wheelRadius: 0.39, wheelZ: 1.40, wheelX: 0.87, pillars: [ - 0.30, - 1.16 ], lamps: [ 0.91, 0.94 ], sign: false, rails: true } }; BODY_SPECS.taxi = Object.assign( {}, BODY_SPECS.sedan, { sign: true } ); function buildBody( spec ) { const profile = spec.body; const radius = spec.wheelRadius + 0.055; const stations = new Set( profile.map( section => section[ 0 ] ) ); // Sample the arches around the axle, then interpolate the body profile at // those stations. Wheel size and placement no longer need hand-shaped cuts. for ( const axle of [ - spec.wheelZ, spec.wheelZ ] ) { for ( let i = 0; i <= 6; i ++ ) stations.add( axle + radius * Math.cos( i / 6 * Math.PI ) ); } const sections = Array.from( stations ).sort( ( a, b ) => b - a ).map( z => { let index = 0; while ( index < profile.length - 2 && z < profile[ index + 1 ][ 0 ] ) index ++; const a = profile[ index ], b = profile[ index + 1 ]; const t = ( z - a[ 0 ] ) / ( b[ 0 ] - a[ 0 ] ); const w = a[ 1 ] + ( b[ 1 ] - a[ 1 ] ) * t; const shoulder = a[ 2 ] + ( b[ 2 ] - a[ 2 ] ) * t; const deck = a[ 3 ] + ( b[ 3 ] - a[ 3 ] ) * t; const distance = Math.abs( Math.abs( z ) - spec.wheelZ ); const sill = distance <= radius ? spec.wheelRadius + Math.sqrt( Math.max( 0, radius * radius - distance * distance ) ) : 0.28; const right = [ new Vector3( w * 0.82, sill, z ), new Vector3( w * 0.97, sill + ( shoulder - sill ) * 0.12, z ), new Vector3( w, shoulder, z ), new Vector3( w * 0.91, deck - 0.025, z ), new Vector3( w * 0.52, deck, z ) ]; return [ ...right, ...right.slice().reverse().map( p => new Vector3( - p.x, p.y, p.z ) ) ].reverse(); } ); const geometry = part( new LoftGeometry( sections, { capStart: true, capEnd: true } ), BODY ); const normals = geometry.attributes.normal; const ids = geometry.attributes.partId; for ( let i = 0; i < normals.count; i ++ ) { if ( normals.getZ( i ) > 0.9999 ) ids.setX( i, FRONT ); if ( normals.getZ( i ) < - 0.9999 ) ids.setX( i, REAR ); } return geometry; } // Independent panel vertices preserve the crease at each pillar and roof edge. // Their UVs also give the material an exact outline for the window seals. function panel( corners, id, curved = false ) { const geometry = new BufferGeometry(); const columns = curved ? 4 : 1, rows = curved ? 2 : 1; const positions = [], uvs = [], indices = []; const normal = corners[ 1 ].clone().sub( corners[ 0 ] ).cross( corners[ 3 ].clone().sub( corners[ 0 ] ) ).normalize(); for ( let y = 0; y <= rows; y ++ ) { const v = y / rows; for ( let x = 0; x <= columns; x ++ ) { const u = x / columns; const p = corners[ 0 ].clone().lerp( corners[ 1 ], u ).lerp( corners[ 3 ].clone().lerp( corners[ 2 ], u ), v ); if ( curved ) { const arch = 4 * u * ( 1 - u ); p.y += arch * v * 0.035; p.addScaledVector( normal, arch * 4 * v * ( 1 - v ) * 0.025 ); } p.toArray( positions, positions.length ); uvs.push( u, v ); if ( x < columns && y < rows ) { const a = y * ( columns + 1 ) + x, b = a + 1, d = a + columns + 1, c = d + 1; indices.push( a, b, d, b, c, d ); } } } geometry.setAttribute( 'position', new Float32BufferAttribute( positions, 3 ) ); geometry.setAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) ); geometry.setIndex( indices ); geometry.computeVertexNormals(); return part( geometry, id ); } function buildCarGeometry( spec ) { const parts = [ buildBody( spec ) ]; const corner = ( point, side ) => new Vector3( point[ 0 ] * side, point[ 1 ], point[ 2 ] ); const fl = corner( spec.front.base, - 1 ), fr = corner( spec.front.base, 1 ); const rl = corner( spec.rear.base, - 1 ), rr = corner( spec.rear.base, 1 ); const tfl = corner( spec.front.roof, - 1 ), tfr = corner( spec.front.roof, 1 ); const trl = corner( spec.rear.roof, - 1 ), trr = corner( spec.rear.roof, 1 ); parts.push( panel( [ fl, fr, tfr, tfl ], WINDOW, true ), panel( [ rr, rl, trl, trr ], WINDOW, true ), panel( [ fr, rr, trr, tfr ], WINDOW ), panel( [ rl, fl, tfl, trl ], WINDOW ) ); const roof = [ spec.front.roof, spec.rear.roof ].map( ( [ width, y, z ] ) => Array.from( { length: 5 }, ( _, i ) => { const u = i / 4; return new Vector3( ( u * 2 - 1 ) * width, y + 4 * u * ( 1 - u ) * 0.035, z ); } ) ); parts.push( part( new LoftGeometry( roof, { closed: false } ), BODY ) ); const r = spec.wheelRadius; const rim = r * 0.64; const width = r * 0.68; const wheelProfile = [ new Vector2( r * 0.93, - width * 0.44 ), new Vector2( r, - width * 0.12 ), new Vector2( r * 0.98, width * 0.27 ), new Vector2( rim + 0.014, width * 0.44 ) ]; for ( const side of [ - 1, 1 ] ) { for ( const z of [ - spec.wheelZ, spec.wheelZ ] ) { const x = side * spec.wheelX; const tyre = new LatheGeometry( wheelProfile, 24 ).rotateZ( - side * Math.PI / 2 ).translate( x, r, z ); const lip = new LatheGeometry( [ new Vector2( rim + 0.014, width * 0.44 ), new Vector2( rim, width * 0.29 ) ], 24 ).rotateZ( - side * Math.PI / 2 ).translate( x, r, z ); const hub = new CircleGeometry( rim, 24 ); hub.attributes.position.setZ( 0, - 0.015 ); hub.computeVertexNormals(); hub.rotateY( side * Math.PI / 2 ).translate( x + side * width * 0.29, r, z ); const well = new CircleGeometry( r + 0.06, 8, 0, Math.PI ).rotateY( side * Math.PI / 2 ).translate( x - side * ( width * 0.5 + 0.02 ), r, z ); parts.push( part( tyre, TYRE ), part( lip, ALLOY ), part( hub, ALLOY ), part( well, TRIM ) ); } const mirror = new BoxGeometry( 0.16, 0.1, 0.2 ).rotateY( side * 0.2 ).translate( side * ( spec.body[ 2 ][ 1 ] + 0.06 ), spec.front.base[ 1 ] + 0.05, spec.front.base[ 2 ] - 0.16 ); parts.push( part( mirror, MIRROR ) ); if ( spec.rails ) { const sections = [ 0.06, 0.13, 0.87, 0.94 ].map( ( t, i ) => { const z = tfr.z + ( trr.z - tfr.z ) * t; const roofWidth = tfr.x + ( trr.x - tfr.x ) * t; const y = tfr.y + ( trr.y - tfr.y ) * t + ( 1 - ( 0.63 / roofWidth ) ** 2 ) * 0.035 + ( i === 0 || i === 3 ? 0.005 : 0.05 ); const x = side * 0.63; return [ new Vector3( x - 0.022, y - 0.018, z ), new Vector3( x + 0.022, y - 0.018, z ), new Vector3( x + 0.022, y + 0.018, z ), new Vector3( x - 0.022, y + 0.018, z ) ].reverse(); } ); parts.push( part( new LoftGeometry( sections, { capStart: true, capEnd: true } ), TRIM ) ); } } if ( spec.sign ) { const roofY = ( tfr.y + trr.y ) / 2 + 0.04; const section = ( w, d, y ) => [ new Vector3( w, y, d ), new Vector3( - w, y, d ), new Vector3( - w, y, - d ), new Vector3( w, y, - d ) ]; const sign = new LoftGeometry( [ section( 0.16, 0.065, roofY + 0.1 ), section( 0.20, 0.095, roofY ) ], { capStart: true, capEnd: true } ).translate( 0, 0, - 0.2 ); parts.push( part( sign, SIGN ) ); } return mergeGeometries( parts ); } function roundedRect( point, halfSize, radius ) { const q = point.abs().sub( halfSize ).add( radius ); const distance = q.max( 0 ).length().add( q.x.max( q.y ).min( 0 ) ).sub( radius ); const edge = distance.fwidth().max( 0.001 ); return smoothstep( edge, edge.negate(), distance ); } function createCarMaterial( spec ) { // Dimensions are uniforms, keeping one shader pipeline across body types. const paint = attribute( 'paintColor', 'vec3' ); const axle = uniform( spec.wheelZ ); const radius = uniform( spec.wheelRadius ); const lamps = uniform( new Vector2( ...spec.lamps ) ); const belt = uniform( spec.front.base[ 1 ] ); const doorEnd = spec.rails ? spec.pillars[ 1 ] - 0.08 : spec.rear.base[ 2 ] + 0.1; const seams = uniform( new Vector3( spec.front.base[ 2 ] - 0.04, spec.pillars[ 0 ], doorEnd ) ); const handles = uniform( new Vector2( spec.pillars[ 0 ] + 0.17, doorEnd + 0.17 ) ); const pillars = uniformArray( [ spec.pillars[ 0 ], spec.pillars[ 1 ] ?? 9 ] ); const partId = varying( attribute( 'partId', 'float' ) ).setInterpolation( InterpolationSamplingType.FLAT, InterpolationSamplingMode.EITHER ); const isWindow = partId.equal( WINDOW ); const isTyre = partId.equal( TYRE ); const isAlloy = partId.equal( ALLOY ); const isTrim = partId.equal( TRIM ); const isMirror = partId.equal( MIRROR ); const isSign = partId.equal( SIGN ); const p = positionGeometry; const side = normalGeometry.x.abs().greaterThan( 0.5 ); // Rounded panes and their rubber seals follow the panel UVs. Only the side // panes receive pillars, leaving both windscreens uninterrupted. const paneUV = uv().sub( 0.5 ); let glass = roundedRect( paneUV, vec2( 0.455, 0.405 ), 0.035 ); let seal = roundedRect( paneUV, vec2( 0.47, 0.43 ), 0.045 ); for ( let i = 0; i < 2; i ++ ) { const distance = p.z.sub( pillars.element( i ) ).abs(); glass = glass.mul( select( side, smoothstep( 0.043, 0.053, distance ), 1 ) ); seal = seal.mul( select( side, smoothstep( 0.026, 0.035, distance ), 1 ) ); } const windowColor = mix( mix( paint, color( 0x13191c ), seal ), color( 0x1d2b35 ), glass ); const mirrorGlass = isMirror.and( normalGeometry.z.lessThan( - 0.5 ) ); const glazing = select( isWindow, glass, select( mirrorGlass, 1, 0 ) ); const wheel = vec2( p.z.abs().sub( axle ), p.y.sub( radius ) ); const wheelDistance = wheel.length(); const flank = smoothstep( 0.65, 0.85, p.x.abs() ); const archShade = smoothstep( radius.add( 0.025 ), radius.add( 0.095 ), wheelDistance ); const shading = mix( float( 1 ), archShade.mul( 0.28 ).add( 0.72 ), flank ).mul( smoothstep( 0.25, 0.65, p.y ).mul( 0.3 ).add( 0.7 ) ); let seam = float( 0 ); for ( const z of [ seams.x, seams.y, seams.z ] ) seam = seam.max( smoothstep( 0.012, 0.004, p.z.sub( z ).abs() ) ); for ( const z of [ handles.x, handles.y ] ) seam = seam.max( roundedRect( vec2( p.z.sub( z ), p.y.sub( belt.sub( 0.10 ) ) ), vec2( 0.06, 0.011 ), 0.006 ) ); seam = seam.mul( flank ).mul( smoothstep( 0.36, 0.43, p.y ) ).mul( smoothstep( belt, belt.sub( 0.025 ), p.y ) ); let bodyColor = paint.mul( shading ).mul( seam.mul( 0.5 ).oneMinus() ); // Lamps, grille, number plates and bumper inlets are inset into the fascia. // The front and rear caps supply their silhouettes without floating boxes. const front = partId.equal( FRONT ); const rear = partId.equal( REAR ); const end = front.or( rear ); const lampY = select( front, lamps.x, lamps.y ); const light = roundedRect( vec2( p.x.abs().sub( 0.61 ), p.y.sub( lampY ) ), vec2( 0.19, 0.055 ), 0.018 ).mul( select( end, 1, 0 ) ); const grille = roundedRect( vec2( p.x, p.y.sub( lamps.x.sub( 0.025 ) ) ), vec2( 0.30, 0.075 ), 0.025 ).mul( select( front, 1, 0 ) ); const intake = roundedRect( vec2( p.x, p.y.sub( lampY.sub( 0.29 ) ) ), vec2( 0.66, 0.045 ), 0.03 ).mul( select( end, 1, 0 ) ); const plateY = select( front, lamps.x.sub( 0.20 ), lamps.y.sub( 0.17 ) ); const plate = roundedRect( vec2( p.x, p.y.sub( plateY ) ), vec2( 0.155, 0.055 ), 0.008 ).mul( select( end, 1, 0 ) ); const slats = p.y.mul( 65 ).fract().step( 0.5 ).mul( 0.35 ).add( 0.65 ); bodyColor = mix( bodyColor, color( 0x161a1c ).mul( slats ), grille.max( intake ) ); bodyColor = mix( bodyColor, color( 0xd8d9d3 ), plate ); bodyColor = mix( bodyColor, select( front, color( 0xd1e4eb ), color( 0x7b1015 ) ), light ); const spokeAngle = atan( wheel.y, wheel.x ).mul( 5 / ( Math.PI * 2 ) ); const spokes = smoothstep( 0.62, 0.42, spokeAngle.fract().sub( 0.5 ).abs().mul( 2 ) ); const rim = smoothstep( radius.mul( 0.53 ), radius.mul( 0.59 ), wheelDistance ); const hub = smoothstep( 0.06, 0.035, wheelDistance ); const alloy = mix( color( 0x171b20 ), color( 0xafb6ba ), spokes.max( rim ).max( hub ) ); const tyre = color( 0x18191b ).mul( smoothstep( radius.mul( 0.85 ), radius.mul( 0.94 ), wheelDistance ).mul( 0.2 ).add( 0.8 ) ); const material = new MeshStandardNodeMaterial(); material.colorNode = select( isTyre, tyre, select( isAlloy, alloy, select( isTrim, color( 0x202326 ), select( isSign, color( 0xffd66a ), select( isWindow, windowColor, select( mirrorGlass, color( 0x70808a ), bodyColor ) ) ) ) ) ); material.roughnessNode = select( isTyre.or( isTrim ), float( 0.85 ), mix( float( 0.32 ), float( 0.055 ), glazing ) ); material.metalnessNode = select( isAlloy, float( 0.8 ), select( isTyre.or( isTrim ).or( isSign ), float( 0 ), mix( float( 0.25 ), float( 0.85 ), glazing ) ) ); material.emissiveNode = select( front, color( 0xd9efff ).mul( 4 ), color( 0xf00008 ).mul( 1.5 ) ).mul( light ).add( select( isSign, color( 0xffd77b ).mul( 2 ), color( 0x000000 ) ) ); return material; } export { CarGenerator };