UNPKG

three

Version:

JavaScript 3D library

254 lines (175 loc) 9.25 kB
import { ExtrudeGeometry, Group, InstancedMesh, MeshStandardNodeMaterial, Shape } from 'three/webgpu'; import { cameraPosition, color, float, floor, Fn, fract, fwidth, If, mix, mx_noise_float, normalView, normalWorldGeometry, positionView, positionWorld, sin, smoothstep } from 'three/tsl'; /** * Generates the raised sidewalk for a city's blocks: per block, a rounded-corner concrete * slab rimmed by a distinct granite kerbstone that stands proud of the walking surface and * drops to the road. Instanced across a list of placements and dressed with its own * procedural material ( poured concrete flags, scored expansion joints, granite curb ). * Returns a `THREE.Group` of two instanced meshes — the walking slab and the curb. * * Unlike the building generator, this one owns its materials: the slab and curb * geometry and the TSL that shades them live together here. * * ```js * const sidewalk = new SidewalkGenerator( { width: 90, depth: 60, height: 0.5 } ); * scene.add( sidewalk.build( placements ) ); // placements: Matrix4[] * ``` */ class SidewalkGenerator { constructor( parameters = {} ) { this.parameters = Object.assign( {}, SidewalkGenerator.defaults, parameters ); this.material = null; // the procedural concrete, built once and reused across rebuilds this.curbMaterial = null; // the procedural granite curb, likewise this.mesh = null; } build( placements ) { this.dispose(); const { width, depth, height, radius, curbWidth, curbLip } = this.parameters; if ( this.material === null ) this.material = createSidewalkMaterial(); if ( this.curbMaterial === null ) this.curbMaterial = createCurbMaterial(); // the walking slab and the curb are separate meshes so each carries its own material const slab = new InstancedMesh( slabGeometry( width, depth, height, radius, curbWidth ), this.material, placements.length ); const curb = new InstancedMesh( curbGeometry( width, depth, height, radius, curbWidth, curbLip ), this.curbMaterial, placements.length ); for ( let i = 0; i < placements.length; i ++ ) { slab.setMatrixAt( i, placements[ i ] ); curb.setMatrixAt( i, placements[ i ] ); } slab.computeBoundingSphere(); curb.computeBoundingSphere(); slab.receiveShadow = curb.receiveShadow = true; const group = new Group(); group.name = 'Sidewalk'; group.add( slab, curb ); this.mesh = group; return group; } dispose() { if ( this.mesh === null ) return; this.mesh.traverse( ( o ) => o.geometry && o.geometry.dispose() ); this.mesh = null; } } SidewalkGenerator.defaults = { width: 90, // the block footprint each slab covers depth: 60, height: 0.5, // walking-surface height above the road radius: 5, // corner radius, so the sidewalk turns each intersection instead of a hard 90° curbWidth: 0.13, // top width of the granite kerbstone rimming the block ( ~5 in ) curbLip: 0.01 // how far the curb stands proud of the walking surface ( near-flush ) }; // --- geometry ------------------------------------------------------------ // the block footprint as a rounded-corner rectangle ( centred at the origin ), so the // sidewalk turns each intersection instead of meeting the kerb at a hard 90° function roundedRect( width, depth, radius ) { const w = width / 2; const d = depth / 2; const r = Math.min( radius, w, d ); const shape = new Shape(); shape.moveTo( - w + r, - d ); shape.lineTo( w - r, - d ); shape.quadraticCurveTo( w, - d, w, - d + r ); shape.lineTo( w, d - r ); shape.quadraticCurveTo( w, d, w - r, d ); shape.lineTo( - w + r, d ); shape.quadraticCurveTo( - w, d, - w, d - r ); shape.lineTo( - w, - d + r ); shape.quadraticCurveTo( - w, - d, - w + r, - d ); return shape; } // extrude a footprint outline up by `height` ( the extrusion runs +Z; stand it up so height is +Y ) function extrudeUp( shape, height ) { const geometry = new ExtrudeGeometry( shape, { depth: height, bevelEnabled: false, curveSegments: 6 } ); geometry.rotateX( - Math.PI / 2 ); return geometry; } // the walking slab: the inner concrete surface, inset to sit inside the curb and overlapping // it slightly so the seam is buried. base at y = 0, walking surface at `height`. function slabGeometry( width, depth, height, radius, curbWidth ) { const innerRadius = Math.max( 0.5, radius - curbWidth ); return extrudeUp( roundedRect( width - 2 * curbWidth + 0.06, depth - 2 * curbWidth + 0.06, innerRadius ), height ); } // the curb: a distinct full-height kerbstone band rimming the block ( the outline with an // inset hole ), standing proud of the walking slab by `curbLip` and dropping to the road. function curbGeometry( width, depth, height, radius, curbWidth, curbLip ) { const innerRadius = Math.max( 0.5, radius - curbWidth ); const shape = roundedRect( width, depth, radius ); shape.holes.push( roundedRect( width - 2 * curbWidth, depth - 2 * curbWidth, innerRadius ) ); return extrudeUp( shape, height + curbLip ); } // --- material ------------------------------------------------------------ // derivative-based bump for a procedural, world-space height field. the built-in bumpMap // offsets the UV to read its height, so it returns a zero gradient for a height keyed off // world position; this feeds the hardware screen-space derivatives of the height into // Mikkelsen's surface-gradient method so the relief actually perturbs the normal. function bumpNormal( height ) { const dpdx = positionView.dFdx(); const dpdy = positionView.dFdy(); const r1 = dpdy.cross( normalView ); const r2 = normalView.cross( dpdx ); const det = dpdx.dot( r1 ); const grad = det.sign().mul( height.dFdx().mul( r1 ).add( height.dFdy().mul( r2 ) ) ); return det.abs().mul( normalView ).sub( grad ).normalize(); } // an antialiased line repeated at every multiple of `period` ( the scored joints ) function gridLine( coord, period, halfWidth ) { const g = coord.div( period ); const d = float( 0.5 ).sub( fract( g ).sub( 0.5 ).abs() ); // distance to nearest line, in periods const aa = fwidth( g ).max( 0.0001 ); const hw = halfWidth / period; return smoothstep( float( hw ).add( aa ), float( hw ).sub( aa ), d ); } // a noise term that only resolves up close: sampled inside a detail branch ( and kept in its // own single-output Fn, so it is evaluated only in the output flow that consumes it ) function detailNoise( p, detail, scale, amp ) { return Fn( () => { const n = float( 0 ).toVar(); If( detail.greaterThan( 0 ), () => { n.assign( mx_noise_float( p.mul( scale ) ).mul( amp ) ); } ); return n; } )(); } function createSidewalkMaterial() { // concrete flags: each poured slab a slightly different tone, fine aggregate speckle // and expansion joints scored on a grid both ways const p = positionWorld; const detail = smoothstep( 200, 18, p.distance( cameraPosition ) ); const panel = 1.5; // flag size ( ~5 ft NYC sidewalk flags ) const panelHash = fract( sin( floor( p.x.div( panel ) ).mul( 127.1 ).add( floor( p.z.div( panel ) ).mul( 311.7 ) ) ).mul( 43758.5453 ) ); const tone = mx_noise_float( p.mul( 0.5 ) ).mul( 0.5 ).add( 0.5 ); // fine aggregate speckle ( grit, tinting the colour ) and grain relief ( driving the normal ) const grit = detailNoise( p, detail, 14, 0.07 ).mul( detail ); const grain = detailNoise( p, detail, 3, 0.003 ); const base = mix( color( 0x6f6f68 ), color( 0x8c8c82 ), tone ).mul( panelHash.sub( 0.5 ).mul( 0.16 ).add( 1 ) ); // per-flag tone const concrete = base.add( grit ); const joints = gridLine( p.x, panel, 0.045 ).max( gridLine( p.z, panel, 0.045 ) ).mul( detail ); const material = new MeshStandardNodeMaterial(); material.colorNode = concrete.mul( joints.mul( 0.45 ).oneMinus() ); material.roughnessNode = float( 0.92 ).sub( panelHash.mul( 0.05 ) ); material.normalNode = bumpNormal( grain.sub( joints.mul( 0.012 ) ).mul( detail ) ); // world units: ~3 mm grain, ~12 mm scored joints return material; } function createCurbMaterial() { // granite kerbstone: a dense, cool grey stone — darker and smoother than the concrete // flags — with a fine speckle, segment joints every ~1.5 m and a grimier road-facing face const p = positionWorld; const detail = smoothstep( 200, 18, p.distance( cameraPosition ) ); const tone = mx_noise_float( p.mul( 0.6 ) ).mul( 0.5 ).add( 0.5 ); const stone = mix( color( 0x46463f ), color( 0x5c5c54 ), tone ).add( detailNoise( p, detail, 18, 0.05 ).mul( detail ) ); // dark cool granite, fine speckle const seg = 1.5; // kerbstone segment length const joints = gridLine( p.x, seg, 0.04 ).max( gridLine( p.z, seg, 0.04 ) ).mul( detail ); const top = smoothstep( 0.5, 0.85, normalWorldGeometry.y ); // 1 on the curb top, 0 on its walls const dressed = mix( stone.mul( 0.7 ), stone, top ).mul( joints.mul( 0.4 ).oneMinus() ); // grimier on the road-facing face const material = new MeshStandardNodeMaterial(); material.colorNode = dressed; material.roughnessNode = float( 0.7 ).add( tone.mul( 0.1 ) ); // flamed granite: matte, a touch smoother than the concrete sidewalk material.normalNode = bumpNormal( detailNoise( p, detail, 4, 0.002 ).mul( detail ) ); // fine granite grain return material; } export { SidewalkGenerator };