diff --git a/examples/files.json b/examples/files.json index 318161ba0a3e25..e6b6e3574f0acb 100644 --- a/examples/files.json +++ b/examples/files.json @@ -344,6 +344,8 @@ "webgpu_equirectangular", "webgpu_fog_height", "webgpu_furnace_test", + "webgpu_generator_building", + "webgpu_generator_city", "webgpu_geometry_loft", "webgpu_hdr", "webgpu_instance_mesh", diff --git a/examples/jsm/generators/CityGenerator.js b/examples/jsm/generators/CityGenerator.js new file mode 100644 index 00000000000000..627c3e981a40d9 --- /dev/null +++ b/examples/jsm/generators/CityGenerator.js @@ -0,0 +1,346 @@ +import { + Group, + Matrix4 +} from 'three'; + +import { MeshStandardNodeMaterial } from 'three/webgpu'; +import { cameraPosition, color, float, floor, Fn, fract, fwidth, hash, If, mix, mod, mx_fractal_noise_float, mx_noise_float, normalView, positionView, positionWorld, smoothstep, step, uint, varying, vec4 } from 'three/tsl'; + +import { SkyscraperGenerator, createSkyscraperMaterial, buildingPalette } from './city/SkyscraperGenerator.js'; +import { SidewalkGenerator } from './city/SidewalkGenerator.js'; + +/** + * Lays out a grid of city blocks and fills each lot with a {@link SkyscraperGenerator} + * tower of its own seed, height and footprint, optionally on raised sidewalk + * slabs (curbs). Returns a `THREE.Group` ready to add to a scene. + * + * Pass a building material to dress the towers; the sidewalks dress themselves + * via {@link SidewalkGenerator}. The layout is exposed as + * {@link CityGenerator#layout} so the surrounding scene (road markings, etc.) + * can align to the same grid. + * + * ```js + * const city = new CityGenerator( { seed: 1 } ); + * scene.add( city.build( materials ) ); + * ``` + */ +class CityGenerator { + + constructor( parameters = {} ) { + + this.parameters = Object.assign( {}, CityGenerator.defaults, parameters ); + this.layout = cityLayout( this.parameters ); + + this.generators = []; + this.sidewalk = new SidewalkGenerator( { + width: this.layout.blockW, + depth: this.layout.blockD, + height: this.parameters.curbHeight, + radius: this.parameters.curbRadius + } ); + this.group = null; + + } + + build( materials = {} ) { + + this.dispose(); + + const group = new Group(); + group.name = 'City'; + + const L = this.layout; + const random = createRandom( this.parameters.seed ); + + // raise the lots onto rounded sidewalk slabs ( curbs ) when curbHeight > 0 + + const curb = this.parameters.curbHeight; + const slabs = []; + + for ( let bx = 0; bx < L.blocksX; bx ++ ) { + + for ( let bz = 0; bz < L.blocksZ; bz ++ ) { + + const blockX = - L.cityW / 2 + bx * ( L.blockW + L.street ); + const blockZ = - L.cityD / 2 + bz * ( L.blockD + L.street ); + + if ( curb > 0 ) { + + slabs.push( new Matrix4().makeTranslation( blockX + L.blockW / 2, 0, blockZ + L.blockD / 2 ) ); + + } + + for ( let lx = 0; lx < L.lotsX; lx ++ ) { + + for ( let lz = 0; lz < L.lotsZ; lz ++ ) { + + // a chamfered corner only reads as architecture when it faces the + // block's corner ( the street intersection ), so only the four corner + // lots are cut, each toward its own outward corner; the rest stay square + const cornerX = lx === 0 ? - 1 : ( lx === L.lotsX - 1 ? 1 : 0 ); + const cornerZ = lz === 0 ? - 1 : ( lz === L.lotsZ - 1 ? 1 : 0 ); + const onCorner = cornerX !== 0 && cornerZ !== 0; + + const tall = random(); + + const generator = new SkyscraperGenerator( { + seed: Math.floor( random() * 100000 ), + totalHeight: 38 + tall * tall * 114, // a few tall towers, mostly mid-rise + footprint: { width: L.lot - 1 - random() * 4, depth: L.lot - 1 - random() * 4 }, // nearly fill the lot so neighbours sit close; width and depth vary independently + floorHeight: 3.4 + random() * 1.8, + bayWidth: 1.9 + random() * 2.1, + pierWidth: 0.4 + random() * 0.5, + pierDepth: 0.3 + random() * 0.4, + chamferWidth: onCorner ? 3 + random() * 4 : 0, + chamferCornerX: cornerX, + chamferCornerZ: cornerZ, + setbackDepth: random() < 0.4 ? 0.8 + random() * 2 : 0, // only some towers step back at the crown; the rest rise flat + stringCourseEvery: random() < 0.85 ? 3 + Math.floor( random() * 6 ) : 0 + }, materials.building ); + + const building = generator.build(); + building.position.set( blockX + ( lx + 0.5 ) * L.lot, curb, blockZ + ( lz + 0.5 ) * L.lot ); + building.castShadow = building.receiveShadow = true; + + group.add( building ); + this.generators.push( generator ); + + } + + } + + } + + } + + if ( slabs.length > 0 ) group.add( this.sidewalk.build( slabs ) ); + + this.group = group; + + return group; + + } + + dispose() { + + for ( const generator of this.generators ) generator.dispose(); + this.generators.length = 0; + + this.sidewalk.dispose(); + + this.group = null; + + } + +} + +CityGenerator.defaults = { + seed: 1, + street: 22, + lot: 30, + lotsX: 3, + lotsZ: 2, + blocksX: 2, + blocksZ: 2, + curbHeight: 0.15, // ~6 in standard curb reveal / sidewalk height above the road + curbRadius: 5 +}; + +// derives the block / street dimensions from the parameters +function cityLayout( parameters ) { + + const { street, lot, lotsX, lotsZ, blocksX, blocksZ } = parameters; + + const blockW = lotsX * lot; + const blockD = lotsZ * lot; + + return { + street, lot, lotsX, lotsZ, blocksX, blocksZ, blockW, blockD, + cityW: blocksX * blockW + ( blocksX - 1 ) * street, + cityD: blocksZ * blockD + ( blocksZ - 1 ) * street + }; + +} + +// deterministic PRNG (mulberry32) so a seed always lays out the same city +function createRandom( seed ) { + + let s = ( seed >>> 0 ) || 1; + + return function () { + + s = ( s + 0x6D2B79F5 ) | 0; + let t = Math.imul( s ^ ( s >>> 15 ), 1 | s ); + t = ( t + Math.imul( t ^ ( t >>> 7 ), 61 | t ) ) ^ t; + return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296; + + }; + +} + +// --- road 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(); + +} + +// antialiased filled band: 1 where |coord| < halfWidth, edge sized to the +// pixel footprint ( fwidth ) so thin road paint stays crisp and doesn't shimmer +function lineAA( coord, halfWidth ) { + + const aa = fwidth( coord ).max( 0.0001 ); + return smoothstep( float( halfWidth ).add( aa ), float( halfWidth ).sub( aa ), coord.abs() ); + +} + +// the same, repeated at every multiple of `period` ( stripes, 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 ); + +} + +/** + * The shared material every tower in a {@link CityGenerator} is dressed with: one flat + * masonry colour per lot, picked from a palette by hashing the lot's grid cell. + */ +function createBuildingMaterial( layout, seed = 0 ) { + + // every tower takes one flat colour, picked by hashing its lot — one shared material + // dresses the whole skyline; common tones repeat so the equal-probability pick feels real + const palette = buildingPalette.map( hex => color( hex ) ); + + const periodX = layout.blockW + layout.street; + const periodZ = layout.blockD + layout.street; + const gx = positionWorld.x.add( layout.cityW / 2 ); + const gz = positionWorld.z.add( layout.cityD / 2 ); + const blockIX = floor( gx.div( periodX ) ); + const blockIZ = floor( gz.div( periodZ ) ); + const cellX = blockIX.mul( layout.lotsX ).add( floor( gx.sub( blockIX.mul( periodX ) ).div( layout.lot ) ) ); + const cellZ = blockIZ.mul( layout.lotsZ ).add( floor( gz.sub( blockIZ.mul( periodZ ) ).div( layout.lot ) ) ); + const cellKey = uint( cellX.add( 4096 ) ).mul( uint( 73856093 ) ).bitXor( uint( cellZ.add( 4096 ) ).mul( uint( 19349663 ) ) ).bitXor( uint( ( seed * 2654435761 ) >>> 0 ) ).toVar(); + const cellHash = ( a, b ) => hash( cellKey.add( uint( Math.round( ( a + b * 7 ) * 100 ) ) ) ); + + const pick = cellHash( 127.1, 311.7 ); + let buildingBase = palette[ 0 ]; + for ( let i = 1; i < palette.length; i ++ ) buildingBase = mix( buildingBase, palette[ i ], step( i / palette.length, pick ) ); + buildingBase = buildingBase.mul( cellHash( 269.5, 183.3 ).mul( 0.12 ).add( 0.94 ) ); // subtle per-building brightness + + // the pick is constant across a tower, so resolve it once per vertex ( varying ) + return createSkyscraperMaterial( varying( buildingBase ) ); + +} + +/** + * The road surface: wet asphalt with lane lines and crosswalks aligned to a + * {@link CityGenerator} layout. Apply it to a ground plane sized to the city. + */ +function createRoadMaterial( layout ) { + + // wet asphalt: a warm-grey base in patchwork pours, two-scale aggregate + // grit, oily wear stains, hairline cracks and low-frequency wet patches + // that turn glossy and mirror the sky. detail fades in as the camera nears. + + const p = positionWorld; + const detail = smoothstep( 240, 25, p.distance( cameraPosition ) ); + + const blotch = mx_fractal_noise_float( p.mul( 0.2 ), 3 ).mul( 0.5 ).add( 0.5 ); + + // close-range detail — aggregate grit, oily wear pools, hairline cracks and worn + // paint — only resolves near the camera, so its noise is sampled ( inside the branch ) + // only where detail is non-zero and skipped across the far majority of the road + const near = Fn( () => { + + const grit = float( 0 ).toVar(); // two scales of aggregate, -1..1 + const stain = float( 0 ).toVar(); // oily wear pools + const crack = float( 0 ).toVar(); + const worn = float( 1 ).toVar(); // paint rubbed thin and patchy, more so where tyres cross it + + If( detail.greaterThan( 0 ), () => { + + grit.assign( mx_noise_float( p.mul( 7 ) ).add( mx_noise_float( p.mul( 23 ) ) ).mul( 0.5 ) ); + stain.assign( smoothstep( 0.5, 0.85, mx_fractal_noise_float( p.mul( 0.45 ), 3 ).mul( 0.5 ).add( 0.5 ) ) ); + crack.assign( smoothstep( 0.88, 1, mx_fractal_noise_float( p.mul( 1.1 ), 4 ).abs().oneMinus() ).mul( detail ) ); + worn.assign( smoothstep( 0.25, 0.7, mx_fractal_noise_float( p.mul( 0.7 ), 3 ).mul( 0.5 ).add( 0.5 ) ).mul( 0.55 ).add( 0.35 ) ); + + } ); + + return vec4( grit, stain, crack, worn ); + + } )(); + + const grit = near.x; + const stain = near.y; + const crack = near.z; + const worn = near.w; + + const base = mix( color( 0x24262b ), color( 0x3b3f46 ), blotch ); + const gritty = base.mul( grit.mul( 0.22 ).mul( detail ).add( 1 ) ); + const asphalt = mix( gritty, gritty.mul( 0.5 ), stain.mul( 0.5 ).mul( detail ) ); + + const wet = smoothstep( 0.6, 0.85, mx_fractal_noise_float( p.mul( 0.14 ), 2 ).mul( 0.5 ).add( 0.5 ) ); + + // markings, aligned to the block / street grid. fx, fz are the position + // within one block+street period; the street is the [ blockW, period ) part. + + const periodX = layout.blockW + layout.street; + const periodZ = layout.blockD + layout.street; + const fx = mod( p.x.add( layout.cityW / 2 ), periodX ); + const fz = mod( p.z.add( layout.cityD / 2 ), periodZ ); + const inStreetX = step( layout.blockW, fx ); // in a vertical street ( gap in X ) + const inStreetZ = step( layout.blockD, fz ); // in a horizontal street ( gap in Z ) + const su = fx.sub( layout.blockW ); // across the vertical street + const sv = fz.sub( layout.blockD ); // across the horizontal street + + // lane markings down each street ( not through intersections ): a solid + // centre line splitting the two directions, with a dashed divider in each + // half, so every street carries four lanes + const dashV = step( fract( p.z.div( 7 ) ), 0.5 ); + const dashH = step( fract( p.x.div( 7 ) ), 0.5 ); + + const centreV = lineAA( su.sub( layout.street / 2 ), 0.12 ); + const dividerV = lineAA( su.sub( layout.street / 4 ), 0.1 ).max( lineAA( su.sub( layout.street * 3 / 4 ), 0.1 ) ).mul( dashV ); + const laneV = centreV.max( dividerV ).mul( inStreetX ).mul( inStreetZ.oneMinus() ); + + const centreH = lineAA( sv.sub( layout.street / 2 ), 0.12 ); + const dividerH = lineAA( sv.sub( layout.street / 4 ), 0.1 ).max( lineAA( sv.sub( layout.street * 3 / 4 ), 0.1 ) ).mul( dashH ); + const laneH = centreH.max( dividerH ).mul( inStreetZ ).mul( inStreetX.oneMinus() ); + + // continental crosswalk bars ( long in the travel direction ) in each + // street arm, near the block edges it meets + const cw = 5; + const nearZ = step( fz, cw ).max( step( layout.blockD - cw, fz ) ); + const nearX = step( fx, cw ).max( step( layout.blockW - cw, fx ) ); + const crossV = gridLine( su, 1.2, 0.38 ).mul( inStreetX ).mul( inStreetZ.oneMinus() ).mul( nearZ ); + const crossH = gridLine( sv, 1.2, 0.38 ).mul( inStreetZ ).mul( inStreetX.oneMinus() ).mul( nearX ); + + const paint = laneV.max( laneH ).max( crossV ).max( crossH ).mul( detail ).mul( worn ); + + const material = new MeshStandardNodeMaterial(); + const surface = mix( asphalt, asphalt.mul( 0.6 ), wet ).mul( crack.mul( 0.5 ).oneMinus() ); + material.colorNode = mix( surface, color( 0xd0ccc0 ), paint ); // worn white paint + material.roughnessNode = mix( float( 0.95 ).sub( paint.mul( 0.2 ) ), float( 0.32 ), wet ); + material.normalNode = bumpNormal( grit.mul( 0.003 ).sub( crack.mul( 0.01 ) ).mul( detail ) ); // world units: ~3 mm aggregate, ~10 mm cracks + + return material; + +} + +export { CityGenerator, createBuildingMaterial, createRoadMaterial }; diff --git a/examples/jsm/generators/ForestGenerator.js b/examples/jsm/generators/ForestGenerator.js new file mode 100644 index 00000000000000..9c4391f42bdb6b --- /dev/null +++ b/examples/jsm/generators/ForestGenerator.js @@ -0,0 +1,347 @@ +import { + BufferAttribute, + Group, + IcosahedronGeometry, + InstancedBufferAttribute, + InstancedMesh, + Object3D, + Vector3 +} from 'three'; + +import { MeshStandardNodeMaterial } from 'three/webgpu'; +import { attribute, color, float, Fn, If, mix, mx_noise_float, normalView, positionLocal, positionView, positionWorld, smoothstep, step, uniform } from 'three/tsl'; + +import { ImprovedNoise } from '../math/ImprovedNoise.js'; +import { mergeVertices } from '../utils/BufferGeometryUtils.js'; + +/** + * Carpets a {@link TerrainGenerator} ( or anything exposing `sampleHeight`, + * `sampleSlope`, `minY`, `maxY` and `parameters.size` ) with a forest of hundreds + * of thousands of trees in a single draw call. + * + * Each tree is the cheapest thing that still reads as a tree: a ~20-face icosphere + * squashed into a tapered teardrop and lumped with a little noise, carrying a baked + * dark-base / bright-top gradient. Tens of triangles each, so a single + * {@link THREE.InstancedMesh} of half a million of them costs one draw call. Trees + * are placed by rejection sampling against ecological rules — a min/max altitude + * band ( above the mist floor, below the snowline ), a slope limit ( none on + * cliffs ) and a low-frequency density mask that opens clearings — then jittered in + * yaw, lean and ( squared-biased ) scale so the stand never reads as copies. + * + * ```js + * const forest = new ForestGenerator( { count: 500000 } ); + * scene.add( forest.build( terrain ) ); + * ``` + */ +class ForestGenerator { + + constructor( parameters = {} ) { + + this.parameters = Object.assign( {}, ForestGenerator.defaults, parameters ); + + // stochastic distance cull ( THREE.Fog-style near / far ): drawn within `from`, gone + // past `to`, the band between thinned by a baked random. live-tunable uniforms. + this.from = uniform( this.parameters.from ); + this.to = uniform( this.parameters.to ); + + // main-camera position ( set via setCameraPosition ). NOT the TSL cameraPosition node: + // in the shadow pass that resolves to the light, which would cull the wrong trees. + this._cameraPosition = uniform( new Vector3() ); + + this.material = createForestMaterial( this.from, this.to, this._cameraPosition ); + this.mesh = null; + this.group = null; + + } + + build( terrain ) { + + this.dispose(); + + const p = this.parameters; + const geometry = blobGeometry( p ); + + const size = terrain.parameters.size; + const minY = terrain.minY; + const span = terrain.maxY - terrain.minY; + + const random = createRandom( p.seed ); + + // a low-frequency field that breaks the forest into patches and clearings + const perlin = new ImprovedNoise(); + const dOffX = random() * 256, dOffZ = random() * 256, dSlice = random() * 256; + const densityAt = ( x, z ) => smoothBlend( - 0.12, 0.22, perlin.noise( x * p.densityFrequency + dOffX, z * p.densityFrequency + dOffZ, dSlice ) ); + + const mesh = new InstancedMesh( geometry, this.material, p.count ); + mesh.castShadow = mesh.receiveShadow = p.castShadow; // honoured on every rebuild + + // per-instance cull data: xyz = tree position ( for its distance to the camera ), + // w = a threshold jitter from a separate PRNG, so it doesn't disturb placement + const cullData = new Float32Array( p.count * 4 ); + const cullRandom = createRandom( ( p.seed ^ 0x9e3779b9 ) >>> 0 ); + + // per-instance regional colour drift, baked here so the vertex-bound shader taps no + // noise. offsets come from the cull PRNG, so placement is untouched. + const regionData = new Float32Array( p.count ); + const rOffX = cullRandom() * 256, rOffZ = cullRandom() * 256, rSlice = cullRandom() * 256; + + const dummy = new Object3D(); + let placed = 0; + let attempts = 0; + const maxAttempts = p.count * 14; // give up rather than hang if the band is too small + + while ( placed < p.count && attempts < maxAttempts ) { + + attempts ++; + + const x = ( random() - 0.5 ) * size; + const z = ( random() - 0.5 ) * size; + + const y = terrain.sampleHeight( x, z ); + const altitude = ( y - minY ) / span; + if ( altitude < p.altitudeMin || altitude > p.altitudeMax ) continue; + + if ( terrain.sampleSlope( x, z ) < p.minSlope ) continue; + + // density mask, feathered out at the top so the treeline scatters, not a clean line + let density = densityAt( x, z ); + density *= smoothBlend( p.altitudeMax, p.altitudeMax - 0.14, altitude ); + if ( random() >= density ) continue; + + dummy.position.set( x, y - p.sink, z ); // sink the base point into the ground + dummy.rotation.set( ( random() - 0.5 ) * 0.12, random() * Math.PI * 2, ( random() - 0.5 ) * 0.12 ); // small lean + free yaw, trunk ~vertical + + const s = p.minScale + random() * random() * ( p.maxScale - p.minScale ); // squared bias: mostly small, rare giants + dummy.scale.set( s * ( 0.85 + random() * 0.3 ), s, s * ( 0.85 + random() * 0.3 ) ); + + dummy.updateMatrix(); + mesh.setMatrixAt( placed, dummy.matrix ); + + const c = placed * 4; + cullData[ c ] = x; + cullData[ c + 1 ] = dummy.position.y; // the sunk y, matching the drawn position + cullData[ c + 2 ] = z; + cullData[ c + 3 ] = cullRandom(); + + regionData[ placed ] = Math.min( 1, Math.max( 0, perlin.noise( x * 0.02 + rOffX, z * 0.02 + rOffZ, rSlice ) * 0.6 + 0.5 ) ); + + placed ++; + + } + + mesh.count = placed; // only what got planted + mesh.instanceMatrix.needsUpdate = true; + geometry.setAttribute( 'cull', new InstancedBufferAttribute( cullData, 4 ) ); + geometry.setAttribute( 'region', new InstancedBufferAttribute( regionData, 1 ) ); + + const group = new Group(); + group.name = 'Forest'; + group.add( mesh ); + + this.mesh = mesh; + this.group = group; + + return group; + + } + + // call each frame so the distance cull tracks the camera + setCameraPosition( position ) { + + this._cameraPosition.value.copy( position ); + + } + + dispose() { + + if ( this.mesh ) this.mesh.geometry.dispose(); + this.mesh = null; + this.group = null; + + } + +} + +ForestGenerator.defaults = { + seed: 1, + count: 500000, // number of trees to plant ( a single instanced draw call ) + detail: 0, // icosphere subdivision ( 0 = 20 faces, welds to 12 verts ) + radius: 1.3, // base half-width of a tree blob, in world units + height: 4, // base height of a tree blob + distortion: 0.5, // lumpiness of the blob hull ( a rough conifer, not a smooth egg ) + sink: 0.4, // how far the base point is pushed under the surface, to hide it + altitudeMin: 0.12, // normalised altitude band the forest occupies: above the mist floor... + altitudeMax: 0.46, // ...and safely below the snowline + minSlope: 0.55, // minimum surface flatness ( normal.y ); steeper ground stays bare rock + densityFrequency: 0.012, // patch / clearing scale ( world units ) + minScale: 0.7, + maxScale: 1.8, + from: 300, // distance ( like THREE.Fog ) within which every tree is drawn... + to: 620, // ...past which none are; the band between thins out stochastically + castShadow: false // whether the canopy casts + receives shadows ( 500k casters is a real cost — opt in ) +}; + +// deterministic PRNG ( mulberry32 ), matching the other generators +function createRandom( seed ) { + + let s = ( seed >>> 0 ) || 1; + + return function () { + + s = ( s + 0x6D2B79F5 ) | 0; + let t = Math.imul( s ^ ( s >>> 15 ), 1 | s ); + t = ( t + Math.imul( t ^ ( t >>> 7 ), 61 | t ) ) ^ t; + return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296; + + }; + +} + +function smoothBlend( edge0, edge1, x ) { + + const t = Math.max( 0, Math.min( 1, ( x - edge0 ) / ( edge1 - edge0 ) ) ); + return t * t * ( 3 - 2 * t ); + +} + +// smooth low-frequency lump over the unit sphere, so the blob hull is bumpy not spiky +function blobNoise( x, y, z ) { + + return Math.sin( x * 3.1 ) * Math.sin( y * 2.7 + 1.3 ) * Math.sin( z * 3.5 + 2.1 ); + +} + +// one tree blob: an icosphere squashed into a lumpy, tapered teardrop, base at y = 0. +// normals are re-pointed up-and-out so it shades as a soft canopy volume; a baked `ao` +// ( 0 base → 1 crown ) drives the dark-underside / bright-crown gradient. +function blobGeometry( p ) { + + // IcosahedronGeometry is non-indexed ( 60 verts ); deleting uv + normal lets mergeVertices + // weld by position to 12 verts — ~5× fewer vertex-shader runs. normals are rebuilt below. + let geometry = new IcosahedronGeometry( 1, p.detail ); + geometry.deleteAttribute( 'uv' ); + geometry.deleteAttribute( 'normal' ); + geometry = mergeVertices( geometry ); + + const position = geometry.attributes.position; + const count = position.count; + + const normals = new Float32Array( count * 3 ); + const ao = new Float32Array( count ); + + for ( let i = 0; i < count; i ++ ) { + + const ux = position.getX( i ); + const uy = position.getY( i ); + const uz = position.getZ( i ); // a point on the unit sphere + + const h = ( uy + 1 ) / 2; // 0 at the base, 1 at the top + const taper = 1 - 0.62 * h; // narrower toward a pointier crown + const lump = 1 + p.distortion * blobNoise( ux, uy, uz ); + const r = taper * lump; + + position.setXYZ( i, ux * r * p.radius, h * p.height, uz * r * p.radius ); + + // up-and-outward normal: a soft, dome-lit canopy rather than faceted rock + const inv = 1 / Math.hypot( ux, 0.55, uz ); + normals[ i * 3 ] = ux * inv; + normals[ i * 3 + 1 ] = 0.55 * inv; + normals[ i * 3 + 2 ] = uz * inv; + + ao[ i ] = h; + + } + + position.needsUpdate = true; + geometry.setAttribute( 'normal', new BufferAttribute( normals, 3 ) ); + geometry.setAttribute( 'ao', new BufferAttribute( ao, 1 ) ); + geometry.computeBoundingSphere(); + + return geometry; + +} + +// derivative-based bump ( surface-gradient method ): perturbs the view normal from a +// procedural height field, so the canopy reads as clustered foliage, not a smooth shell +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(); + +} + +/** + * The single material shared by every tree in a {@link ForestGenerator}. A plain + * MeshStandardNodeMaterial lit by the scene — only the surface is authored: deep + * shadowed green in the recesses rising to a bright, yellow-green sunlit crown, + * mottled into needle clumps by 3D noise, with a matching bump so the clumps catch + * the light. Half a million instanced blobs makes this mesh vertex-bound, so the + * regional colour drift is baked to a per-instance attribute ( no shader noise for it ), + * and the costly clump noise + bump are **gated by distance** — full detail on the near + * trees ( where it reads ), skipped on the far canopy ( where it is sub-pixel ). + * + * @param {Node} from - distance within which every tree is drawn. + * @param {Node} to - distance past which no tree is drawn. + * @return {MeshStandardNodeMaterial} + */ +function createForestMaterial( from, to, camPos ) { + + const material = new MeshStandardNodeMaterial(); + material.metalness = 0; + material.roughness = 0.88; + + const cull = attribute( 'cull', 'vec4' ); // xyz = tree position, w = random 0..1 + const d = cull.xyz.distance( camPos ); // per-tree distance to the ( main ) camera + + // stochastic distance cull: past its jittered `from`→`to` threshold a tree collapses to a + // point, dropping the far canopy. `positionLocal` is already WORLD space here ( the instance + // transform runs before positionNode ), so the ×0 lands the whole blob on the origin. + const t = d.sub( from ).div( to.sub( from ) ); + material.positionNode = positionLocal.mul( step( t, cull.w ) ); // keep where random ≥ t + + const ao = attribute( 'ao', 'float' ); // 0 at the blob base, 1 at the crown + + // regional drift, baked per tree ( see build ) so no stage taps a noise; a blob is small + // enough that one value per tree reads as a smooth field across the canopy + const region = attribute( 'region', 'float' ); + const deep = mix( color( 0x1d3318 ), color( 0x2e4420 ), region ); // shadowed interior + const bright = mix( color( 0x4c6a2e ), color( 0x6e8a40 ), region ); // sunlit tips ( muted green, not neon ) + + // one 3D noise field ( coarse + fine ), shared by the colour and bump, near canopy only + const detailFade = smoothstep( 280, 25, positionWorld.distance( camPos ) ); + + // gated by an If ( which must sit inside an Fn ) so the far canopy skips the noise + const clump = Fn( () => { + + const c = float( 0 ).toVar(); + + If( detailFade.greaterThan( 0.01 ), () => { + + c.assign( mx_noise_float( positionWorld.mul( 0.9 ) ) + .add( mx_noise_float( positionWorld.mul( 3.1 ) ).mul( 0.5 ) ) + .mul( detailFade ) ); + + } ); + + return c; + + } )(); + + // deep recesses → bright clumps / crown + const lit = ao.mul( 0.5 ).add( 0.32 ).add( clump.mul( 0.18 ) ).clamp(); + material.colorNode = mix( deep, bright, lit ); + + // clumps catch the light ( clump is 0 far away, so the bump flattens there ) + material.normalNode = bumpNormal( clump.mul( 0.22 ) ); + + return material; + +} + +export { ForestGenerator, createForestMaterial }; diff --git a/examples/jsm/generators/TerrainGenerator.js b/examples/jsm/generators/TerrainGenerator.js new file mode 100644 index 00000000000000..dc7bc304ee2030 --- /dev/null +++ b/examples/jsm/generators/TerrainGenerator.js @@ -0,0 +1,504 @@ +import { + BufferGeometry, + Float32BufferAttribute, + Group, + Mesh +} from 'three'; + +import { MeshStandardNodeMaterial } from 'three/webgpu'; +import { cameraPosition, color, float, Fn, If, mix, mx_noise_float, normalView, normalWorld, positionView, positionWorld, saturation, smoothstep, uniform } from 'three/tsl'; + +import { ImprovedNoise } from '../math/ImprovedNoise.js'; + +/** + * Bakes a procedural mountain range into a single {@link THREE.BufferGeometry} and + * returns a `THREE.Group` ready to add to a scene. + * + * The heightfield is a derivative-damped fractal sum ( Quilez's fake erosion ): each + * octave is suppressed where the running slope is already steep, concentrating detail + * into weathered ridgelines, and a low-frequency domain warp makes those ridges + * meander. A few passes of thermal ( talus ) erosion then relax any slope past the + * angle of repose, settling the fractal's needle-spikes into real crests. + * + * The grid is triangulated with alternating quad diagonals ( a diamond pattern ), so a + * coarse mesh holds its silhouette without a one-way grain. The surface shades itself + * from altitude and slope in TSL — grass, forest, rock, scree and snow, with detail + * normals and aerial perspective — so no material or textures are needed. + * + * The baked height grid is exposed through {@link TerrainGenerator#sampleHeight} so a + * scattered forest ( or anything else ) can sit exactly on the surface. + * + * ```js + * const terrain = new TerrainGenerator( { seed: 1 } ); + * scene.add( terrain.build() ); + * ``` + */ +class TerrainGenerator { + + constructor( parameters = {} ) { + + this.parameters = Object.assign( {}, TerrainGenerator.defaults, parameters ); + + // baked altitude range, fed to the shader so the colour bands track the real + // valley floor and peaks + this.minHeight = uniform( 0 ); + this.maxHeight = uniform( 1 ); + + this.material = terrainMaterial( this.minHeight, this.maxHeight ); + this.geometry = null; + this.group = null; + + } + + build() { + + this.dispose(); + + const p = this.parameters; + const N = p.segments + 1; + const half = p.size / 2; + + // world coordinate of each grid line, shared by the bake and layout below + const coord = new Array( N ); + for ( let i = 0; i < N; i ++ ) coord[ i ] = i / p.segments * p.size - half; + + // bake the height grid; kept around so the surface can be sampled ( bilinearly ) + // afterwards — e.g. to sit a scattered forest on it + const height = heightField( p ); + const heights = new Float32Array( N * N ); + + for ( let iz = 0; iz < N; iz ++ ) { + + for ( let ix = 0; ix < N; ix ++ ) { + + heights[ iz * N + ix ] = height( coord[ ix ], coord[ iz ] ); + + } + + } + + // relax slopes past the angle of repose, shedding the fractal's needle-spikes + if ( p.talusPasses > 0 ) thermalErode( heights, N, p.size / p.segments, p.talus, p.talusPasses ); + + // lay the grid out flat in the XZ plane ( Y-up ) and find the height range + const positions = new Float32Array( N * N * 3 ); + let min = Infinity, max = - Infinity; + + for ( let iz = 0; iz < N; iz ++ ) { + + for ( let ix = 0; ix < N; ix ++ ) { + + const o = iz * N + ix; + const y = heights[ o ]; + + positions[ o * 3 ] = coord[ ix ]; + positions[ o * 3 + 1 ] = y; + positions[ o * 3 + 2 ] = coord[ iz ]; + + if ( y < min ) min = y; + if ( y > max ) max = y; + + } + + } + + // flip the quad diagonal on every other quad, so the mesh reads as diamonds + // rather than a one-way grain + const indices = []; + + for ( let iz = 0; iz < p.segments; iz ++ ) { + + for ( let ix = 0; ix < p.segments; ix ++ ) { + + const a = iz * N + ix, b = a + 1, c = a + N, d = c + 1; + + if ( ( ix + iz ) % 2 === 0 ) indices.push( a, c, b, b, c, d ); + else indices.push( a, c, d, a, d, b ); + + } + + } + + const geometry = new BufferGeometry(); + geometry.setAttribute( 'position', new Float32BufferAttribute( positions, 3 ) ); + geometry.setIndex( indices ); + geometry.computeVertexNormals(); + + this.heights = heights; + this.gridSize = N; + this.minY = min; + this.maxY = max; + this.minHeight.value = min; + this.maxHeight.value = max; + + const mesh = new Mesh( geometry, this.material ); + mesh.castShadow = mesh.receiveShadow = true; + + const group = new Group(); + group.name = 'Terrain'; + group.add( mesh ); + + this.geometry = geometry; + this.group = group; + + return group; + + } + + // world-space height at ( x, z ), bilinearly interpolated from the baked grid + sampleHeight( x, z ) { + + const p = this.parameters; + const N = this.gridSize; + const half = p.size / 2; + + const fx = Math.max( 0, Math.min( p.segments, ( x + half ) / p.size * p.segments ) ); + const fz = Math.max( 0, Math.min( p.segments, ( z + half ) / p.size * p.segments ) ); + + const ix = Math.min( N - 2, Math.floor( fx ) ); + const iz = Math.min( N - 2, Math.floor( fz ) ); + const tx = fx - ix; + const tz = fz - iz; + + const h = this.heights; + const h00 = h[ iz * N + ix ]; + const h10 = h[ iz * N + ix + 1 ]; + const h01 = h[ ( iz + 1 ) * N + ix ]; + const h11 = h[ ( iz + 1 ) * N + ix + 1 ]; + + return ( h00 * ( 1 - tx ) + h10 * tx ) * ( 1 - tz ) + ( h01 * ( 1 - tx ) + h11 * tx ) * tz; + + } + + // surface flatness at ( x, z ): the normal's y component ( 1 on the flat, → 0 on a + // cliff ). allocation-free, for cheaply testing many candidate forest positions. + sampleSlope( x, z ) { + + const e = this.parameters.size / this.parameters.segments; + const hx = this.sampleHeight( x + e, z ) - this.sampleHeight( x - e, z ); + const hz = this.sampleHeight( x, z + e ) - this.sampleHeight( x, z - e ); + + return 2 * e / Math.sqrt( hx * hx + 4 * e * e + hz * hz ); + + } + + dispose() { + + if ( this.geometry ) this.geometry.dispose(); + this.geometry = null; + this.group = null; + + } + +} + +TerrainGenerator.defaults = { + seed: 1, + size: 200, // world units across the square patch + segments: 192, // grid quads per side; vertices = ( segments + 1 )² + heightScale: 65, // peak-to-valley exaggeration, in world units + frequency: 0.01, // base noise frequency ( the footprint of a mountain ) + octaves: 5, + lacunarity: 1.97, // per-octave frequency step; off 2 so octaves don't grid-lock + gain: 0.5, // per-octave amplitude step ( persistence ) + erosion: 0.7, // derivative damping: higher flattens valleys and sharpens ridges + warp: 0.35, // domain-warp strength ( noise units ): bends ridges and valleys + valleyBias: 1.2, // power curve over the height, to flatten the mist floor + seaLevel: 0.15, // 0..1, subtracted before scaling so the valley floor sinks below y = 0 + talus: 1, // thermal-erosion angle of repose ( rise / run ): lower settles flatter + talusPasses: 12 // thermal-erosion iterations ( 0 = off ) +}; + +// deterministic PRNG ( mulberry32 ), so a seed always bakes the same terrain +function createRandom( seed ) { + + let s = ( seed >>> 0 ) || 1; + + return function () { + + s = ( s + 0x6D2B79F5 ) | 0; + let t = Math.imul( s ^ ( s >>> 15 ), 1 | s ); + t = ( t + Math.imul( t ^ ( t >>> 7 ), 61 | t ) ) ^ t; + return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296; + + }; + +} + +// builds the height( worldX, worldZ ) function for one seed +function heightField( p ) { + + const perlin = new ImprovedNoise(); + const random = createRandom( p.seed ); + + // ImprovedNoise's permutation is fixed, so a seed can only shift the sample window: + // a translation and a per-octave z-slice, drawn from the PRNG to decorrelate seeds + const offsetX = random() * 256; + const offsetZ = random() * 256; + const slice = random() * 256; + + const { frequency, octaves, lacunarity, gain, erosion, warp, valleyBias, seaLevel, heightScale } = p; + + // low-frequency fractal sum that warps the sample position + function warpField( x, z, zr ) { + + let freq = 1, amp = 1, sum = 0, norm = 0; + + for ( let i = 0; i < 2; i ++ ) { + + sum += amp * perlin.noise( x * freq + offsetX, z * freq + offsetZ, zr + i * 1.7 ); + norm += amp; freq *= lacunarity; amp *= gain; + + } + + return sum / norm; + + } + + // derivative-damped fractal sum: each octave is divided down where the running + // gradient is already steep, keeping ridges crisp and valleys smooth. the domain + // rotates between octaves to break the noise's axis-aligned grid. + function eroded( x, z ) { + + let sum = 0, amp = 1, dX = 0, dZ = 0, px = x, pz = z, freq = 1; + const e = 0.004; // finite-difference step, in noise units + + for ( let i = 0; i < octaves; i ++ ) { + + const zr = slice + i * 1.7; + const bx = px * freq + offsetX, bz = pz * freq + offsetZ; + const n = perlin.noise( bx, bz, zr ); + const nx = perlin.noise( bx + e, bz, zr ); + const nz = perlin.noise( bx, bz + e, zr ); + + // this octave's world-space gradient ( chain rule: × freq ) + dX += ( nx - n ) / e * freq; + dZ += ( nz - n ) / e * freq; + + sum += amp * n / ( 1 + erosion * ( dX * dX + dZ * dZ ) ); + + // rotate the domain ~37° ( the matrix [ 0.8 -0.6 ; 0.6 0.8 ] ) + const rx = 0.8 * px - 0.6 * pz; + pz = 0.6 * px + 0.8 * pz; + px = rx; + + freq *= lacunarity; amp *= gain; + + } + + return sum * 0.5 + 0.5; + + } + + return function ( worldX, worldZ ) { + + const x = worldX * frequency, z = worldZ * frequency; + + // warp the sample so ridges and valleys meander instead of running straight + const wx = x + warp * warpField( x + 1.3, z + 7.2, slice + 40 ); + const wz = z + warp * warpField( x + 5.2, z + 1.3, slice + 70 ); + + // power curve that settles the low ground into a flat mist bed + const h = Math.pow( Math.min( eroded( wx, wz ) * 1.1, 1 ), valleyBias ); + + return ( h - seaLevel ) * heightScale; + + }; + +} + +// thermal ( talus ) erosion on the baked height grid: a cell overhanging a neighbour +// by more than the talus drop sheds the excess downhill, so over a few passes slopes +// relax to the angle of repose. spikes — steep on every side — bleed off fastest; +// broad one-sided faces keep their shape. material is conserved through a delta buffer, +// so the result is independent of cell order. +function thermalErode( h, N, cellSize, talus, passes ) { + + const drop = talus * cellSize; // max height step a slope can hold between two cells + const carry = 0.5; // fraction of the steepest overhang moved per pass ( <= 0.5 = stable ) + const delta = new Float32Array( N * N ); + const ex = [ 0, 0, 0, 0 ]; + const off = [ - 1, 1, - N, N ]; + + for ( let p = 0; p < passes; p ++ ) { + + delta.fill( 0 ); + + for ( let z = 0; z < N; z ++ ) { + + for ( let x = 0; x < N; x ++ ) { + + const i = z * N + x; + const hi = h[ i ]; + + // overhang past the talus drop toward each of the 4 neighbours + ex[ 0 ] = x > 0 ? hi - h[ i - 1 ] - drop : 0; + ex[ 1 ] = x < N - 1 ? hi - h[ i + 1 ] - drop : 0; + ex[ 2 ] = z > 0 ? hi - h[ i - N ] - drop : 0; + ex[ 3 ] = z < N - 1 ? hi - h[ i + N ] - drop : 0; + + let sum = 0, peak = 0; + + for ( let k = 0; k < 4; k ++ ) { + + const d = ex[ k ]; + + if ( d <= 0 ) { + + ex[ k ] = 0; + continue; + + } + + sum += d; + if ( d > peak ) peak = d; + + } + + if ( sum <= 0 ) continue; + + // move a slice of the steepest overhang, split across the downhill + // neighbours in proportion to how far each sits below the talus line + const move = carry * peak; + delta[ i ] -= move; + + for ( let k = 0; k < 4; k ++ ) { + + if ( ex[ k ] > 0 ) delta[ i + off[ k ] ] += move * ex[ k ] / sum; + + } + + } + + } + + for ( let k = 0; k < N * N; k ++ ) h[ k ] += delta[ k ]; + + } + +} + +// --- shading ------------------------------------------------------------- + +// perturbs the normal by a world-space height field using Mikkelsen's surface-gradient +// method. the built-in bumpMap reads height by offsetting the UV — a no-op for a +// world-keyed height — so the height's screen-space derivatives are fed in directly. +// returns a view-space 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(); + +} + +// altitude- and slope-based shading, all in TSL ( no textures ). only the colour, +// roughness and detail normal are authored here; the lighting ( sun, sky fill, the +// snow's warm/cool cast ) comes from the scene's lights and environment. +function terrainMaterial( minHeight, maxHeight ) { + + const material = new MeshStandardNodeMaterial(); + material.metalness = 0; + + const distance = positionWorld.distance( cameraPosition ); + + // the two drivers: normalised altitude ( valley 0 → peak 1 ) and surface flatness + const altitude = positionWorld.y.sub( minHeight ).div( maxHeight.sub( minHeight ) ).clamp(); + const flatness = normalWorld.y.clamp(); // 1 on level ground, 0 on a vertical cliff + const steep = flatness.oneMinus(); + + // three reused noise scales: fine band-edge jitter, grain ( ~5u patches ) and macro + const detail = mx_noise_float( positionWorld.xz.mul( 0.05 ) ); + const grain = mx_noise_float( positionWorld.xz.mul( 0.18 ) ); + const macro = mx_noise_float( positionWorld.xz.mul( 0.012 ) ); + + const grass = color( 0x6e7253 ); // dry sage-olive meadow ( not video-game green ) + const dryGrass = color( 0x8a8550 ); + const forest = color( 0x39402f ); // dark forested mid-slope band, under the trees + const rock = color( 0x736a5f ); // warm grey-brown rock + const scree = color( 0x837a6f ); // brighter broken rock below the cliffs + const lichen = color( 0x6c7355 ); // muted green-grey, patched onto lower rock + const snow = color( 0xe9ecf0 ); // fresh snow; warm-sun / cool-sky cast is from the lighting + const snowDeep = color( 0xccd6e2 ); // cooler wind-packed snow, drifted into patches + + // two band frequencies of lighter / darker stone, wobbled by noise, so cliff faces + // read as layered bedding instead of flat grey + const bandA = positionWorld.y.mul( 0.5 ).add( detail.mul( 3 ) ).add( macro.mul( 4 ) ).sin(); + const bandB = positionWorld.y.mul( 1.4 ).add( grain.mul( 2 ) ).sin(); + const strata = bandA.mul( 0.6 ).add( bandB.mul( 0.4 ) ).mul( 0.5 ).add( 0.5 ); + + // lichen creeps onto the lower, gentler rock; cliffs and high ground stay bare grey + const lichenMask = smoothstep( 0.45, 0.72, grain ).mul( smoothstep( 0.62, 0.32, steep ) ).mul( smoothstep( 0.66, 0.34, altitude ) ); + const rockShade = mix( rock, lichen, lichenMask.mul( 0.45 ) ).mul( strata.mul( 0.36 ).add( 0.8 ) ); + + // meadow, drifting to dry grass in macro-noise patches over a mid band + let surface = mix( grass, dryGrass, smoothstep( 0.15, 0.75, macro ).mul( smoothstep( 0.22, 0.5, altitude ) ) ); + + // dark forested band on the gentle mid-slopes ( where the instanced trees live ) + surface = mix( surface, forest, smoothstep( 0.16, 0.34, altitude ).mul( smoothstep( 0.5, 0.72, flatness ) ).mul( 0.75 ) ); + + // rock by altitude, and on every steep face regardless of height + surface = mix( surface, rockShade, smoothstep( 0.46, 0.64, altitude.add( detail.mul( 0.06 ) ) ) ); + surface = mix( surface, rockShade, smoothstep( 0.34, 0.62, steep ) ); + + // scree on the medium-steep ground below the cliffs, broken up by noise + const screeMask = smoothstep( 0.42, 0.7, steep ).mul( smoothstep( 0.35, 0.7, flatness ) ).mul( detail.mul( 0.5 ).add( 0.5 ) ); + surface = mix( surface, scree, screeMask.mul( 0.5 ) ); + + // snow on high, flat ground; the grain noise breaks the line so rock pokes through + // near the snowline instead of stopping on a clean contour + const snowMask = smoothstep( 0.56, 0.78, altitude.add( detail.mul( 0.08 ) ).add( grain.mul( 0.05 ) ) ).mul( smoothstep( 0.3, 0.6, flatness ) ); + const snowColor = mix( snow, snowDeep, smoothstep( 0.2, 0.7, grain ).mul( 0.6 ) ); // patchy, not a flat sheet + surface = mix( surface, snowColor, snowMask ); + + // dark, damp ground pooling in the low flat creases ( cheap moisture proxy ) + const cavity = smoothstep( 0.24, 0.06, altitude ).mul( flatness ); + surface = surface.mul( cavity.mul( 0.32 ).oneMinus() ); + + // macro drift then a fine grain mottle, so no band is a flat colour + surface = surface.mul( macro.mul( 0.5 ).add( 0.5 ).mul( 0.3 ).add( 0.84 ) ); + surface = surface.mul( grain.mul( 0.5 ).add( 0.5 ).mul( 0.12 ).add( 0.94 ) ); + + // aerial perspective: desaturate and lift distant ground toward a cool haze, so + // depth reads and the range recedes into the mist + const aerial = smoothstep( 180, 820, distance ); + surface = saturation( surface, aerial.oneMinus().mul( 0.5 ).add( 0.5 ) ); + surface = mix( surface, color( 0xcfc8ba ), aerial.mul( 0.62 ) ); // far ridges dissolve into the sky + + material.colorNode = surface; + material.roughnessNode = mix( float( 0.95 ), float( 0.72 ), snowMask ); + + // detail normals: three octaves of world-space relief, faded out with distance so + // they can't alias into fireflies in the haze. gating the noise behind the fade ( a + // real branch ) lets the far majority of this fragment-bound terrain skip the taps. + const detailFade = smoothstep( 420, 60, distance ); + const reliefStrength = mix( float( 0.25 ), float( 0.55 ), steep ); // more on rock, less on grass + const relief = Fn( () => { + + const r = float( 0 ).toVar(); + + If( detailFade.greaterThan( 0.01 ), () => { + + r.assign( mx_noise_float( positionWorld.xz.mul( 0.6 ) ) + .add( mx_noise_float( positionWorld.xz.mul( 1.7 ) ).mul( 0.5 ) ) + .add( mx_noise_float( positionWorld.xz.mul( 4.0 ) ).mul( 0.25 ) ) + .mul( reliefStrength ).mul( detailFade ).mul( 0.25 ) ); + + } ); + + return r; + + } )(); + + material.normalNode = bumpNormal( relief ); + + return material; + +} + +export { TerrainGenerator }; diff --git a/examples/jsm/generators/city/SidewalkGenerator.js b/examples/jsm/generators/city/SidewalkGenerator.js new file mode 100644 index 00000000000000..60cc26843347dd --- /dev/null +++ b/examples/jsm/generators/city/SidewalkGenerator.js @@ -0,0 +1,253 @@ +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 }; diff --git a/examples/jsm/generators/city/SkyscraperGenerator.js b/examples/jsm/generators/city/SkyscraperGenerator.js new file mode 100644 index 00000000000000..69c101e9530ded --- /dev/null +++ b/examples/jsm/generators/city/SkyscraperGenerator.js @@ -0,0 +1,1357 @@ +import { + BoxGeometry, + BufferAttribute, + BufferGeometry, + ExtrudeGeometry, + InterpolationSamplingMode, + InterpolationSamplingType, + LatheGeometry, + Matrix3, + Matrix4, + Mesh, + MeshStandardMaterial, + Path, + PlaneGeometry, + ShapeGeometry, + Shape, + Sphere, + Vector2, + Vector3 +} from 'three'; + +import { MeshStandardNodeMaterial } from 'three/webgpu'; +import { attribute, cameraPosition, color, cross, dot, float, floor, Fn, fract, fwidth, hash as ihash, mix, mod, modelWorldMatrixInverse, mx_fractal_noise_float, mx_noise_float, normalLocal, normalView, normalWorldGeometry, positionLocal, positionView, positionWorld, select, smoothstep, step, uint, uv, varying, vec2, vec3, vec4 } from 'three/tsl'; + +import { mergeGeometries } from '../../utils/BufferGeometryUtils.js'; + +const _scale = /*@__PURE__*/ new Vector3(); +const _point = /*@__PURE__*/ new Vector3(); +const _normalMatrix = /*@__PURE__*/ new Matrix3(); +const _identity = /*@__PURE__*/ new Matrix4(); + +// material-zone codes baked per vertex into the merged geometry, so one material can +// branch on partId and shade every zone +const PartId = { WALL: 0, PIER: 1, FRAME: 2, ORNAMENT: 3, GLASS: 4, AC: 5 }; +const { WALL, PIER, FRAME, ORNAMENT, GLASS, AC } = PartId; + +// fraction of a floor's height taken by the glazed opening; the remainder is +// the spandrel band. shared by the window module and the spandrels so they tile. +const WINDOW_HEIGHT_RATIO = 0.62; + +// width of the flat window-frame band around the glazing; shared by the frame module +// and the glass pane so the pane always tucks inside the frame +const WINDOW_BORDER = 0.1; + +// the masonry course module ( brick height × length ). the generator snaps floor and +// bay dimensions to it, and the material's coursing reads the same values, so the +// procedural brickwork lines up with the geometry +const BRICK = { height: 0.3, length: 0.6 }; + +// merging requires all-indexed or all-non-indexed inputs; extrusions are +// non-indexed while boxes/planes are indexed, so normalize before merging + +function merge( geometries ) { + + return mergeGeometries( geometries.map( ( g ) => g.index ? g.toNonIndexed() : g ) ); + +} + +function nonIndexed( geometry ) { + + return geometry.index ? geometry.toNonIndexed() : geometry; + +} + +// the unit box is identical for every building's shell boxes — build it once +const _unitBox = /*@__PURE__*/ nonIndexed( new BoxGeometry( 1, 1, 1 ) ); + +/** + * Bakes a list of instance groups into one non-indexed BufferGeometry. Each group is a + * base geometry ( position + normal + uv ), an array of Matrix4 placements and a `partId` + * written to a per-vertex attribute. Transforming straight into preallocated typed arrays + * avoids mergeGeometries' per-instance allocations; the result is one geometry, ready for + * a single draw call and the compute rasterizer. + */ +function bakeGroups( groups ) { + + let total = 0; + for ( const group of groups ) total += group.geometry.attributes.position.count * group.matrices.length; + + const position = new Float32Array( total * 3 ); + const normal = new Float32Array( total * 3 ); + const uv = new Float32Array( total * 2 ); + const partId = new Float32Array( total ); + // per-window interior-mapping room ( centre + size ) the glass pane looks into; only + // the glass group writes it, every other vertex stays zero. baked per vertex so the + // material reads each building's own room sizes without a global uniform. + const roomCenter = new Float32Array( total * 3 ); + const roomSize = new Float32Array( total * 2 ); + + let w = 0; + + // the bounding sphere falls out of the AABB gathered while transforming, sparing a + // second full pass over the positions ( computeBoundingSphere ) + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = - Infinity, maxY = - Infinity, maxZ = - Infinity; + + for ( const group of groups ) { + + const geometry = group.geometry; + const P = geometry.attributes.position.array; + const N = geometry.attributes.normal.array; + const U = geometry.attributes.uv.array; + const count = geometry.attributes.position.count; + const id = group.partId; + const rooms = group.rooms; // per-instance { center, size }, glass only + const rigid = group.rigid === true; // pure rotation ( + translation ): the normal matrix is the rotation itself + + for ( let i = 0; i < group.matrices.length; i ++ ) { + + const room = rooms ? rooms[ i ] : null; + + const matrix = group.matrices[ i ]; + const e = matrix.elements; + const e0 = e[ 0 ], e1 = e[ 1 ], e2 = e[ 2 ], e4 = e[ 4 ], e5 = e[ 5 ], e6 = e[ 6 ], e8 = e[ 8 ], e9 = e[ 9 ], e10 = e[ 10 ], e12 = e[ 12 ], e13 = e[ 13 ], e14 = e[ 14 ]; + + // for a rigid frame the inverse-transpose equals the rotation, so its columns + // are read straight from the matrix and the per-instance 3×3 inverse is skipped + let n0, n1, n2, n3, n4, n5, n6, n7, n8; + + if ( rigid ) { + + n0 = e0; n1 = e1; n2 = e2; n3 = e4; n4 = e5; n5 = e6; n6 = e8; n7 = e9; n8 = e10; + + } else { + + const ne = _normalMatrix.getNormalMatrix( matrix ).elements; + n0 = ne[ 0 ]; n1 = ne[ 1 ]; n2 = ne[ 2 ]; n3 = ne[ 3 ]; n4 = ne[ 4 ]; n5 = ne[ 5 ]; n6 = ne[ 6 ]; n7 = ne[ 7 ]; n8 = ne[ 8 ]; + + } + + for ( let v = 0; v < count; v ++ ) { + + const v3 = v * 3, w3 = w * 3; + const x = P[ v3 ], y = P[ v3 + 1 ], z = P[ v3 + 2 ]; + const wx = e0 * x + e4 * y + e8 * z + e12; + const wy = e1 * x + e5 * y + e9 * z + e13; + const wz = e2 * x + e6 * y + e10 * z + e14; + position[ w3 ] = wx; position[ w3 + 1 ] = wy; position[ w3 + 2 ] = wz; + if ( wx < minX ) minX = wx; if ( wx > maxX ) maxX = wx; + if ( wy < minY ) minY = wy; if ( wy > maxY ) maxY = wy; + if ( wz < minZ ) minZ = wz; if ( wz > maxZ ) maxZ = wz; + + const nx = N[ v3 ], ny = N[ v3 + 1 ], nz = N[ v3 + 2 ]; + const tx = n0 * nx + n3 * ny + n6 * nz, ty = n1 * nx + n4 * ny + n7 * nz, tz = n2 * nx + n5 * ny + n8 * nz; + const inv = 1 / ( Math.sqrt( tx * tx + ty * ty + tz * tz ) || 1 ); + normal[ w3 ] = tx * inv; normal[ w3 + 1 ] = ty * inv; normal[ w3 + 2 ] = tz * inv; + + uv[ w * 2 ] = U[ v * 2 ]; uv[ w * 2 + 1 ] = U[ v * 2 + 1 ]; + partId[ w ] = id; + + if ( room !== null ) { + + roomCenter[ w3 ] = room.center.x; roomCenter[ w3 + 1 ] = room.center.y; roomCenter[ w3 + 2 ] = room.center.z; + roomSize[ w * 2 ] = room.size.x; roomSize[ w * 2 + 1 ] = room.size.y; + + } + + w ++; + + } + + } + + } + + const geometry = new BufferGeometry(); + geometry.setAttribute( 'position', new BufferAttribute( position, 3 ) ); + geometry.setAttribute( 'normal', new BufferAttribute( normal, 3 ) ); + geometry.setAttribute( 'uv', new BufferAttribute( uv, 2 ) ); + geometry.setAttribute( 'partId', new BufferAttribute( partId, 1 ) ); + geometry.setAttribute( 'roomCenter', new BufferAttribute( roomCenter, 3 ) ); + geometry.setAttribute( 'roomSize', new BufferAttribute( roomSize, 2 ) ); + + geometry.boundingSphere = new Sphere( + new Vector3( ( minX + maxX ) / 2, ( minY + maxY ) / 2, ( minZ + maxZ ) / 2 ), + Math.hypot( maxX - minX, maxY - minY, maxZ - minZ ) / 2 + ); + + return geometry; + +} + +// deterministic PRNG (mulberry32) so a given seed always yields the same tower + +function createRandom( seed ) { + + let s = ( seed >>> 0 ) || 1; + + return function () { + + s = ( s + 0x6D2B79F5 ) | 0; + let t = Math.imul( s ^ ( s >>> 15 ), 1 | s ); + t = ( t + Math.imul( t ^ ( t >>> 7 ), 61 | t ) ) ^ t; + return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296; + + }; + +} + +// a stable per-floor hash ( from the floor index and the face origin ) used to pick the +// interior-mapping room module per floor without allocating a closure each floor +function floorHash( f, frame, k ) { + + const s = Math.sin( f * 12.9898 + frame.origin.x * 0.07 + frame.origin.z * 0.131 + k ) * 43758.5453; + return s - Math.floor( s ); + +} + +// the seed-driven "style" of a tower: footprint proportions, tier split and the +// shaping of piers and base arches. these sit between the fixed defaults and the +// caller's parameters, so any parameter passed in still overrides its seeded value. + +function randomStyle( random ) { + + const base = 0.10 + random() * 0.07; + const crown = 0.08 + random() * 0.08; + + return { + footprint: { width: 26 + random() * 18, depth: 20 + random() * 14 }, + tierFractions: { base, crown }, + pierWidth: 0.4 + random() * 0.4, + pierDepth: 0.3 + random() * 0.3, + windowReveal: 0.12 + random() * 0.1, + stringCourseHeight: 0.5 + random() * 0.5, + archBayWidthRatio: Math.round( 1.5 + random() * 1.5 ), + archRise: 0.4 + random() * 0.5 + }; + +} + +/** + * Generates intricate, tripartite "Beaux-Arts / Neo-Gothic" terracotta + * skyscrapers from a small set of parameters. + * + * The mass is read as a footprint polygon (a rectangle with one chamfered + * corner) split into vertical faces, each split into three tiers — a tall + * arcaded base, a repeating shaft and an ornate crown — then into floors and + * bays. A handful of authored pieces (a pier, a window, a cornice profile, a + * gothic arch) are instanced across the whole tower, then baked — together with + * the bespoke base arcade — into a single non-indexed BufferGeometry tagged with + * a per-vertex `partId` ({@link PartId}) so one material can shade every zone. + * + * The generator is material agnostic — it only produces geometry. Pass a single + * material (e.g. a TSL node material that branches on `partId`) to dress it. + * + * ```js + * const generator = new SkyscraperGenerator( { seed: 35, totalHeight: 140 }, material ); + * scene.add( generator.build() ); // a single Mesh + * ``` + */ +class SkyscraperGenerator { + + constructor( parameters = {}, material = null ) { + + this.parameters = parameters; // caller overrides; defaults + seed fill the rest at build time + this.material = material; // a single material; the look is driven by the baked `partId` attribute + + this.mesh = null; + + } + + setParameters( parameters ) { + + Object.assign( this.parameters, parameters ); + + return this; + + } + + build() { + + const random = createRandom( this.parameters.seed ?? SkyscraperGenerator.defaults.seed ); + + // precedence: fixed defaults < seed-driven style < caller parameters + + const p = Object.assign( {}, SkyscraperGenerator.defaults, randomStyle( random ), this.parameters ); + + // snap the masonry-driving dimensions to the brick module so the procedural + // brickwork ( courses up local Y, columns along each face ) lines up with the + // geometry: a whole number of courses per floor and bricks per bay + const vModule = BRICK.height * 2; // a course pair, so floor / window halves still land on a joint + p.floorHeight = Math.max( vModule * 3, Math.round( p.floorHeight / vModule ) * vModule ); + p.windowHeight = Math.round( p.floorHeight * WINDOW_HEIGHT_RATIO / vModule ) * vModule; + p.bayWidth = Math.max( BRICK.length * 3, Math.round( p.bayWidth / BRICK.length ) * BRICK.length ); + p.pierWidth = Math.max( BRICK.length, Math.round( p.pierWidth / BRICK.length ) * BRICK.length ); + + // vertical layout: base / shaft / crown as whole floor counts, so every floor + // line sits on a course ( the requested total height is rounded to suit ) + const floors = Math.max( 3, Math.round( p.totalHeight / p.floorHeight ) ); + const baseFloors = Math.max( 1, Math.round( floors * p.tierFractions.base ) ); + const crownFloors = Math.max( 1, Math.round( floors * p.tierFractions.crown ) ); + const shaftFloors = Math.max( 1, floors - baseFloors - crownFloors ); + + const baseHeight = baseFloors * p.floorHeight; + const crownHeight = crownFloors * p.floorHeight; + const shaftHeight = shaftFloors * p.floorHeight; + p.totalHeight = baseHeight + shaftHeight + crownHeight; + + const baseTop = baseHeight; + const shaftTop = baseHeight + shaftHeight; + + // one accumulator per kind of part, mostly instance matrices. kept separate so the + // bake below can order them by draw order ( which controls overdraw ), not build order. + + const windows = []; + const glass = []; + const glassRooms = []; // per-glass interior-mapping room ( centre + size ), aligned with `glass` + const backWalls = []; // the thin wall closing the volume behind the glass + const bands = []; // spandrel bands, one at each floor line + const piers = new Map(); // pier height -> matrices, so each tier's continuous piers share one geometry + const trim = []; // cornices and parapets ( axis-aligned unit boxes ) + const acUnits = []; // window air-conditioner boxes on a random subset of shaft windows + const finials = []; // pinnacles along the crown + const extras = []; // bespoke geometry: the base arcade and the setback / roof slabs + + const addPier = ( frame, u, vBottom, height ) => { + + const key = Math.round( height * 1000 ); // bucket equal pier heights ( a number key, no string ) + if ( piers.has( key ) === false ) piers.set( key, [] ); + piers.get( key ).push( frame.matrix( u, vBottom, 0 ) ); + + }; + + // footprints: full mass, and the inset crown after the setback + + const footprint = buildFootprint( p.footprint.width, p.footprint.depth, p.chamferWidth, p.chamferCornerX, p.chamferCornerZ ); + const faces = buildFaces( footprint ); + + const inset = p.setbackDepth * p.bayWidth; + const crownFootprint = buildFootprint( + Math.max( p.bayWidth * 2, p.footprint.width - inset * 2 ), + Math.max( p.bayWidth * 2, p.footprint.depth - inset * 2 ), + Math.max( 0, p.chamferWidth - inset ), + p.chamferCornerX, + p.chamferCornerZ + ); + const crownFaces = buildFaces( crownFootprint ); + + // --- generate the parts ----------------------------------------------- + + const crownCornice = p.stringCourseHeight * 1.6; // the crown's heavy cap; its piers stop below it + + // shaft and crown are the same facade over different faces, spans and pier heights + const tiers = [ + { faces, bottom: baseTop, height: shaftHeight, pierHeight: shaftHeight, ac: acUnits }, + { faces: crownFaces, bottom: shaftTop, height: crownHeight, pierHeight: crownHeight - crownCornice, ac: null } + ]; + + for ( const t of tiers ) { + + for ( const frame of t.faces ) { + + addWindows( frame, windows, glass, glassRooms, t.ac, t.bottom, t.height, p ); + addWall( backWalls, frame, t.bottom, t.bottom + t.height, 0.8, - 0.6 ); + addSpandrelBands( bands, frame, t.bottom, t.height, p ); + addPiers( frame, t.bottom, t.pierHeight, p, addPier ); + + } + + } + + // the base: a gothic arcade, capped by a string course + for ( const frame of faces ) { + + addArcade( extras, frame, baseHeight, p ); + addCornice( trim, frame, baseTop - p.stringCourseHeight, p.stringCourseHeight, 0.5 ); + + } + + // periodic string courses banding the shaft + if ( p.stringCourseEvery > 0 ) { + + for ( let f = p.stringCourseEvery; f < shaftFloors; f += p.stringCourseEvery ) { + + for ( const frame of faces ) addCornice( trim, frame, baseTop + f * p.floorHeight - p.stringCourseHeight * 0.5, p.stringCourseHeight, 0.3 ); + + } + + } + + // the crown's heavy cornice, its parapet and the finials along the top + for ( const frame of crownFaces ) { + + addCornice( trim, frame, p.totalHeight - crownCornice, crownCornice, 0.9 ); + addParapet( trim, frame, p.totalHeight, p ); + addFinials( frame, finials, shaftTop, crownHeight, p ); + + } + + // thin slabs capping the setback ledge and the roof + extras.push( slab( footprint, shaftTop, 0.6 ) ); + extras.push( slab( crownFootprint, p.totalHeight, 0.6 ) ); + + // --- bake every part into one geometry --------------------------------- + + // one mesh = one draw the renderer can't sort, so bake order is draw order: the + // facade front-to-back, the backing wall last so its hidden fragments never shade. + + const groups = [ + { geometry: buildWindowGeometry( p ), matrices: windows, partId: FRAME, rigid: true }, + { geometry: nonIndexed( buildGlassGeometry( p ) ), matrices: glass, partId: GLASS, rooms: glassRooms, rigid: true }, + { geometry: _unitBox, matrices: bands, partId: WALL } + ]; + + for ( const [ key, matrices ] of piers ) groups.push( { geometry: buildPierGeometry( p, key / 1000 ), matrices, partId: PIER, rigid: true } ); + + groups.push( { geometry: _unitBox, matrices: trim, partId: WALL } ); // cornices, parapets + groups.push( { geometry: _unitBox, matrices: acUnits, partId: AC } ); + groups.push( { geometry: nonIndexed( buildFinialGeometry( p ) ), matrices: finials, partId: ORNAMENT, rigid: true } ); + + for ( const geometry of extras ) groups.push( { geometry: nonIndexed( geometry ), matrices: [ _identity ], partId: WALL, rigid: true } ); // base arcade + slabs, in building-local space + + groups.push( { geometry: _unitBox, matrices: backWalls, partId: WALL } ); // last — hidden behind the facade + + const geometry = bakeGroups( groups ); + + const mesh = new Mesh( geometry, this.material || new MeshStandardMaterial( { color: 0xddccaa, roughness: 0.9 } ) ); + mesh.name = 'Skyscraper'; + + this.dispose(); + this.mesh = mesh; + + return mesh; + + } + + rebuild() { + + return this.build(); + + } + + dispose() { + + if ( this.mesh === null ) return; + + this.mesh.geometry.dispose(); + this.mesh = null; + + } + +} + +// fixed baseline. the remaining parameters (footprint, tierFractions, pierWidth, +// pierDepth, windowReveal, stringCourseHeight, archBayWidthRatio, archRise) are +// derived from the seed by randomStyle() unless the caller provides them. +SkyscraperGenerator.defaults = { + seed: 35, + totalHeight: 140, + floorHeight: 4, + bayWidth: 2.6, + stringCourseEvery: 6, + chamferWidth: 4, + chamferCornerX: 1, + chamferCornerZ: 1, + setbackDepth: 1.5, + acChance: 0.12 +}; + +// --- footprint & faces --------------------------------------------------- + +/** + * A rectangle (centred at the origin in the XZ plane) with one corner cut at + * 45 degrees, returned as an ordered list of `Vector2( x, z )`. `cornerX` / + * `cornerZ` ( each ±1 ) pick which corner is cut, so the chamfer can be aimed + * outward to a block corner. + */ +function buildFootprint( width, depth, chamfer, cornerX = 1, cornerZ = 1 ) { + + const hw = width / 2; + const hd = depth / 2; + const c = Math.min( chamfer, hw, hd ); + + // the four corners, counter-clockwise + const corners = [ + new Vector2( hw, hd ), + new Vector2( - hw, hd ), + new Vector2( - hw, - hd ), + new Vector2( hw, - hd ) + ]; + + const points = []; + + for ( let i = 0; i < corners.length; i ++ ) { + + const corner = corners[ i ]; + + // cut the requested corner: replace it with two points pulled back along + // each adjacent edge, leaving a 45° face that points out to that corner + if ( c > 0 && Math.sign( corner.x ) === cornerX && Math.sign( corner.y ) === cornerZ ) { + + const prev = corners[ ( i + 3 ) % 4 ]; + const next = corners[ ( i + 1 ) % 4 ]; + points.push( corner.clone().lerp( prev, c / corner.distanceTo( prev ) ) ); + points.push( corner.clone().lerp( next, c / corner.distanceTo( next ) ) ); + + } else { + + points.push( corner.clone() ); + + } + + } + + return points; + +} + +/** + * Builds a face frame per footprint edge. Each frame is an orthonormal basis + * ( u along the edge, v up, n outward ) plus an origin and length, so all + * facade layout can happen in flat ( u, v ) space and bake to world with one + * matrix — the same authored piece then instances onto every face, including + * the diagonal chamfer. + */ +function buildFaces( points ) { + + const faces = []; + const up = new Vector3( 0, 1, 0 ); + + for ( let i = 0; i < points.length; i ++ ) { + + const a = points[ i ]; + const b = points[ ( i + 1 ) % points.length ]; + + // outward normal: perpendicular to the edge, pointing away from the + // origin (the footprint is centred there) + + const n = new Vector3( b.y - a.y, 0, - ( b.x - a.x ) ).normalize(); + const mid = new Vector3( ( a.x + b.x ) / 2, 0, ( a.y + b.y ) / 2 ); + if ( n.dot( mid ) < 0 ) n.negate(); + + // right-handed basis: u = v × n, so makeBasis( u, v, n ) is a pure rotation + + const u = new Vector3().crossVectors( up, n ).normalize(); + + const pa = new Vector3( a.x, 0, a.y ); + const pb = new Vector3( b.x, 0, b.y ); + const length = pa.distanceTo( pb ); + + // the edge end that u points away from becomes the origin + + const origin = pb.clone().sub( pa ).dot( u ) > 0 ? pa : pb; + + faces.push( new FaceFrame( origin, u, up.clone(), n, length ) ); + + } + + return faces; + +} + +/** A face's local ( u along edge, v up, n outward ) frame in world space. */ +class FaceFrame { + + constructor( origin, u, v, n, length ) { + + this.origin = origin; + this.u = u; + this.v = v; + this.n = n; + this.length = length; + + } + + point( u, v, w, target = new Vector3() ) { + + return target + .copy( this.origin ) + .addScaledVector( this.u, u ) + .addScaledVector( this.v, v ) + .addScaledVector( this.n, w ); + + } + + /** Places a piece authored in the canonical local frame ( x across, y up, z outward ). */ + matrix( u, v, w ) { + + return new Matrix4() + .makeBasis( this.u, this.v, this.n ) + .setPosition( this.point( u, v, w, _point ) ); + + } + + /** How many bays of `bayWidth` fit, with the remainder split into end margins. */ + bays( bayWidth ) { + + const count = Math.max( 1, Math.floor( this.length / bayWidth ) ); + const margin = ( this.length - count * bayWidth ) / 2; + + return { count, margin, width: bayWidth }; + + } + +} + +// --- shell pieces -------------------------------------------------------- + +// a Matrix4 mapping the shared unit box ( 1×1×1, centred ) onto a face-aligned +// box of the given size, centred at the given face-local point. these matrices +// are what the shell InstancedMesh is built from. +function boxMatrix( frame, u, v, w, sizeU, sizeV, sizeN ) { + + return new Matrix4() + .makeBasis( frame.u, frame.v, frame.n ) + .scale( _scale.set( sizeU, sizeV, sizeN ) ) + .setPosition( frame.point( u, v, w, _point ) ); + +} + +function addWall( target, frame, vBottom, vTop, thickness = 0.8, front = 0 ) { + + const h = vTop - vBottom; + target.push( boxMatrix( frame, frame.length / 2, vBottom + h / 2, front - thickness / 2, frame.length + thickness * 2, h, thickness ) ); + +} + +/** + * Horizontal terracotta bands at every floor line. Together with the projecting + * piers they form the facade grid; the gaps between them are the window + * openings, with glass set behind. + */ +function addSpandrelBands( target, frame, vBottom, height, p ) { + + const floors = Math.max( 1, Math.round( height / p.floorHeight ) ); + const fh = height / floors; + const bandHeight = p.floorHeight - p.windowHeight; // whole courses: floor minus the glazed opening + + // pull the ends in by the band depth so a band doesn't poke its end-cap + // into the plane of the perpendicular face at the corners ( overdraw ) + const bandLength = Math.max( 0.2, frame.length - 0.6 ); + + for ( let f = 0; f <= floors; f ++ ) { + + // front flush at w = 0, meeting the backing wall behind + target.push( boxMatrix( frame, frame.length / 2, vBottom + f * fh, - 0.3, bandLength, bandHeight, 0.6 ) ); + + } + +} + +/** + * A thin horizontal cap over a footprint's bounding box at height `y`. Its + * sides are pulled in behind the facade plane ( into the backing-wall shell ) + * so they never sit coplanar with the walls, spandrels or piers and z-fight. + */ +function slab( footprint, y, thickness ) { + + // a thin cap following the footprint OUTLINE ( so the chamfered corner is cut, not + // left overhanging as a rectangular box ), inset a little so its edge tucks just + // behind the facade and the wall top reads as a lip around it + + const inset = 0.8; + let cx = 0, cz = 0; + for ( const p of footprint ) { + + cx += p.x; cz += p.y; + + } + + cx /= footprint.length; cz /= footprint.length; + + // consistent ( CCW ) winding so the extrude caps face up / down correctly + let area = 0; + for ( let i = 0; i < footprint.length; i ++ ) { + + const a = footprint[ i ], b = footprint[ ( i + 1 ) % footprint.length ]; + area += a.x * b.y - b.x * a.y; + + } + + const pts = area < 0 ? footprint.slice().reverse() : footprint; + + const shape = new Shape(); + pts.forEach( ( p, i ) => { + + const dx = cx - p.x, dz = cz - p.y; + const d = Math.hypot( dx, dz ) || 1; + const x = p.x + dx / d * inset; + const z = p.y + dz / d * inset; + if ( i === 0 ) shape.moveTo( x, z ); else shape.lineTo( x, z ); + + } ); + + // extrude the XZ outline downward by the thickness, the top dropped just below height y: + // the inset cap would otherwise sit coplanar with the surrounding wall top faces and + // z-fight, and the parapet / spandrel bands around the edge hide the shallow recess + const drop = 0.2; + const geometry = new ExtrudeGeometry( shape, { depth: thickness, bevelEnabled: false } ); + geometry.rotateX( Math.PI / 2 ); + geometry.translate( 0, y - drop, 0 ); + return geometry; + +} + +/** A two-step projecting cornice / string-course band wrapping a face. */ +function addCornice( target, frame, vBottom, height, depth ) { + + target.push( boxMatrix( frame, frame.length / 2, vBottom + height * 0.275, depth / 2, frame.length, height * 0.55, depth ) ); + target.push( boxMatrix( frame, frame.length / 2, vBottom + height * 0.775, depth * 0.85, frame.length, height * 0.45, depth * 1.7 ) ); + +} + +/** A low parapet wall capping the crown. */ +function addParapet( target, frame, vTop, p ) { + + const height = 1.4; + target.push( boxMatrix( frame, frame.length / 2, vTop + height / 2, p.pierDepth * 0.4, frame.length, height, p.pierDepth * 0.8 ) ); + +} + +/** + * The base storey: a wall pierced by tall pointed-arch openings, extruded with + * thickness so the openings read as deep recesses. + */ +function addArcade( target, frame, height, p ) { + + const archWidth = p.bayWidth * p.archBayWidthRatio; + const { count, margin } = frame.bays( archWidth ); + + const sill = height * 0.04; + const spring = height * 0.55; + const apex = Math.min( height * 0.96, spring + ( archWidth / 2 ) * ( 0.8 + p.archRise ) ); + + const shape = new Shape(); + shape.moveTo( 0, 0 ); + shape.lineTo( frame.length, 0 ); + shape.lineTo( frame.length, height ); + shape.lineTo( 0, height ); + shape.lineTo( 0, 0 ); + + for ( let i = 0; i < count; i ++ ) { + + const cx = margin + ( i + 0.5 ) * archWidth; + const hw = archWidth * 0.34; + + const hole = new Path(); + hole.moveTo( cx - hw, sill ); + hole.lineTo( cx - hw, spring ); + hole.quadraticCurveTo( cx - hw, apex, cx, apex ); + hole.quadraticCurveTo( cx + hw, apex, cx + hw, spring ); + hole.lineTo( cx + hw, sill ); + hole.lineTo( cx - hw, sill ); + shape.holes.push( hole ); + + } + + const thickness = 1.1; + const geometry = new ExtrudeGeometry( shape, { depth: thickness, bevelEnabled: false, curveSegments: 8 } ); + geometry.translate( 0, 0, - thickness ); + geometry.applyMatrix4( frame.matrix( 0, 0, 0 ) ); + + target.push( geometry ); + + // a dark plane set behind the openings so the recesses read + + const back = new PlaneGeometry( frame.length, height ); + back.applyMatrix4( frame.matrix( frame.length / 2, height / 2, - thickness - 0.4 ) ); + target.push( back ); + +} + +// --- repeating field ----------------------------------------------------- + +function addPiers( frame, vBottom, height, p, addPier ) { + + const { count, margin, width } = frame.bays( p.bayWidth ); + + // a pier on every bay edge except the far end: that corner is shared with + // the next face, which places its own pier there, so emitting both would + // stack two piers at each corner + + for ( let i = 0; i < count; i ++ ) { + + addPier( frame, margin + i * width, vBottom, height ); + + } + +} + +function addWindows( frame, windows, glass, glassRooms, acUnits, vBottom, height, p ) { + + const { count, margin, width } = frame.bays( p.bayWidth ); + const floors = Math.max( 1, Math.round( height / p.floorHeight ) ); + const fh = height / floors; + + // a window AC unit sitting on the sill, protruding from the facade. about half the window + // width, capped at a real unit's size ( ~0.66 m ) and kept wider than tall, sticking out + // about half its width + const acW = Math.min( ( p.bayWidth - p.pierWidth ) * 0.55, 0.66 ); + const acH = acW * 0.6; + const acD = acW * 0.5; + const acV = - p.windowHeight / 2 + acH / 2 + WINDOW_BORDER; // bottom rests on the sill ( the top of the window's bottom frame rail ) + + // a real ~0.66 m unit looks lost in a wide opening, so only fit ACs where it still spans a + // fair share of the window — in practice, the narrower ( older-style ) windows + const acFits = acW >= ( width - p.pierWidth ) * 0.34; + + for ( let f = 0; f < floors; f ++ ) { + + const cy = vBottom + ( f + 0.5 ) * fh; + + // the interior-mapping room module: one floor tall, a run of two or three bays + // wide, chosen per floor so neighbouring windows share an interior. the choice + // is deterministic ( seeded by the floor and the face ) so it is stable, and the + // run is recorded per window so the material can ray-march the right box. + const roomBays = floorHash( f, frame, 0 ) > 0.5 ? 3 : 2; + const roomPhase = Math.floor( floorHash( f, frame, 1 ) * roomBays ); + + for ( let b = 0; b < count; b ++ ) { + + const cx = margin + ( b + 0.5 ) * width; + + windows.push( frame.matrix( cx, cy, 0 ) ); + glass.push( frame.matrix( cx, cy, - p.windowReveal ) ); + + // the run of bays this window's room spans, clamped at the face ends, recorded + // as the room's centre on the facade and its width × height in metres + const room = Math.floor( ( b + roomPhase ) / roomBays ); + const bStart = Math.max( 0, room * roomBays - roomPhase ); + const bEnd = Math.min( count, ( room + 1 ) * roomBays - roomPhase ); + const span = bEnd - bStart; + glassRooms.push( { center: frame.point( margin + ( bStart + span / 2 ) * width, cy, - p.windowReveal ), size: new Vector2( span * width, fh - 1 ) } ); // centred on the glass plane, so the interior is anchored to the pane it is drawn on + + if ( acUnits && acFits ) { + + // deterministic per-window hash ( varies per face via the frame origin ) + const r = Math.sin( f * 41.3 + b * 12.7 + frame.origin.x * 0.13 + frame.origin.z * 0.31 ) * 43758.5453; + // the back tucks into the window reveal ( just in front of the glass ) so the unit sits + // in the opening instead of floating on the facade + const acW0 = acD / 2 - p.windowReveal + 0.04; + if ( r - Math.floor( r ) < p.acChance ) acUnits.push( boxMatrix( frame, cx, cy + acV, acW0, acW, acH, acD ) ); + + } + + } + + } + +} + +function addFinials( frame, finials, vBottom, height, p ) { + + const { count, margin, width } = frame.bays( p.bayWidth ); + const top = vBottom + height; + + // skip the far-end bay edge: it is the shared corner the next face also + // caps, so emitting both would stack two finials at each corner + + for ( let i = 0; i < count; i ++ ) { + + finials.push( new Matrix4().setPosition( frame.point( margin + i * width, top, p.pierDepth * 0.5, _point ) ) ); + + } + +} + +// --- authored modules ---------------------------------------------------- + +function buildPierGeometry( p, height ) { + + // a wide pier with a slimmer pilaster raised on its face, giving the + // continuous vertical rib a stepped, terracotta profile + + const back = new BoxGeometry( p.pierWidth, height, p.pierDepth * 0.6 ); + back.translate( 0, height / 2, p.pierDepth * 0.3 ); + + // the pilaster stops just short of the pier top so that where a pier is left + // exposed ( at a setback ) the cap reads as one clean block rather than the + // back box and the pilaster stacked into a T + const pilasterHeight = Math.max( 1, height - 0.6 ); + const front = new BoxGeometry( p.pierWidth * 0.55, pilasterHeight, p.pierDepth * 0.45 ); + front.translate( 0, pilasterHeight / 2, p.pierDepth * 0.6 + p.pierDepth * 0.225 ); + + return merge( [ back, front ] ); + +} + +function buildWindowGeometry( p ) { + + // the flat frame face ( a rectangle with the glazing hole ), the four reveal walls + // of the opening and the glazing bars, merged into one instanced module. a full + // extrusion would also emit a hidden back cap and outer side walls; windows are by + // far the heaviest part of a building, so those are skipped. + + const w = p.bayWidth - p.pierWidth; + const h = p.windowHeight; + const border = WINDOW_BORDER; + const depth = p.windowReveal; // reveal walls run all the way back to the glass ( placed at -windowReveal ), so no gap opens between them and the pane + const iw = w / 2 - border; + const ih = h / 2 - border; + + const shape = new Shape(); + shape.moveTo( - w / 2, - h / 2 ); + shape.lineTo( w / 2, - h / 2 ); + shape.lineTo( w / 2, h / 2 ); + shape.lineTo( - w / 2, h / 2 ); + shape.lineTo( - w / 2, - h / 2 ); + + const hole = new Path(); + hole.moveTo( - iw, - ih ); + hole.lineTo( - iw, ih ); + hole.lineTo( iw, ih ); + hole.lineTo( iw, - ih ); + hole.lineTo( - iw, - ih ); + shape.holes.push( hole ); + + const front = new ShapeGeometry( shape ); // visible frame face, flush with the facade + + // the four reveal walls of the opening, set back to the glazing + const wall = ( x, y, rx, ry, sw, sh ) => { + + const pl = new PlaneGeometry( sw, sh ); + pl.rotateX( rx ); + pl.rotateY( ry ); + pl.translate( x, y, - depth / 2 ); + return pl; + + }; + + const left = wall( - iw, 0, 0, Math.PI / 2, depth, ih * 2 ); + const right = wall( iw, 0, 0, - Math.PI / 2, depth, ih * 2 ); + const sill = wall( 0, - ih, - Math.PI / 2, 0, iw * 2, depth ); + const head = wall( 0, ih, Math.PI / 2, 0, iw * 2, depth ); + + // a single horizontal glazing bar ( transom ), flat, just in front of the glass — + // a thin box would triple the window's triangle count for sub-pixel thickness + const transom = new PlaneGeometry( iw * 2, 0.05 ); + transom.translate( 0, h * 0.04, - depth + 0.02 ); // meeting rail, just above centre + + return merge( [ front, left, right, sill, head, transom ] ); + +} + +function buildGlassGeometry( p ) { + + const w = p.bayWidth - p.pierWidth - WINDOW_BORDER * 2; + const h = p.windowHeight - WINDOW_BORDER * 2; + + return new PlaneGeometry( w, h ); + +} + +function buildFinialGeometry( p ) { + + // a tapering pinnacle revolved around its axis + + const s = p.pierWidth; + const profile = [ + new Vector2( 0.0, 0 ), + new Vector2( s * 0.9, 0 ), + new Vector2( s * 0.9, s * 0.4 ), + new Vector2( s * 0.55, s * 1.0 ), + new Vector2( 0.0, s * 3.2 ) + ]; + + return new LatheGeometry( profile, 8 ); // round enough to read as a smooth pinnacle, still light + +} + +// --- 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(); + +} + +// interior mapping: fakes a furnished room behind each glass pane in the fragment +// shader — no geometry, no texture. every pane carries the room it looks into ( centre + +// size, baked per window by addWindows ), so neighbouring panes share one interior. the +// view ray is cast into that box and the walls, floor, ceiling and a few furniture pieces +// it meets are shaded procedurally, keyed off a per-room hash. returns vec4( colour, lit ). +const interior = /*@__PURE__*/ Fn( () => { + + // flat so floor() below can't split one pane across two cell ids ( centre is per-room ) + const roomCenter = varying( attribute( 'roomCenter', 'vec3' ) ).setInterpolation( InterpolationSamplingType.FLAT, InterpolationSamplingMode.EITHER ); + const roomSize = attribute( 'roomSize', 'vec2' ); + + // a per-face frame from the geometry normal ( holds on every facade, including the + // 45° chamfer ): u runs across the face, v is up, n points outward + const n = normalLocal; + const up = vec3( 0, 1, 0 ); + const uAxis = cross( up, n ).normalize(); + + // this pixel and the view ray, in the room's ( across, up, depth ) frame; depth + // runs into the wall, so the ray's depth component is positive + const d = positionLocal.sub( roomCenter ); + const camLocal = modelWorldMatrixInverse.mul( vec4( cameraPosition, 1 ) ).xyz; + const rayLocal = positionLocal.sub( camLocal ).normalize(); + const origin = vec3( dot( d, uAxis ), d.y, 0 ); + const dir = vec3( dot( rayLocal, uAxis ), rayLocal.y, dot( rayLocal, n ).negate() ); + + // the room box: the pane-wide × ceiling-height front rectangle ( centred on the pane ), + // set back behind the glass and run a little deeper than it is tall. shade the far + // side the ray exits ( slab method: nearest of the three far-plane crossings; + // dividing by a near-zero direction gives ±inf, which min() harmlessly drops ). + const setback = float( 0.1 ); // the room starts just behind the glass, so it sits flush in the frame opening + const boxMax = vec3( roomSize.x.mul( 0.5 ), roomSize.y.mul( 0.5 ), setback.add( roomSize.y.mul( 1.55 ) ) ); + const boxMin = vec3( boxMax.x.negate(), boxMax.y.negate(), setback ); + const tFar = boxMin.sub( origin ).div( dir ).max( boxMax.sub( origin ).div( dir ) ); + const t = tFar.x.min( tFar.y ).min( tFar.z ); + const hit = origin.add( dir.mul( t ) ); + const q = hit.sub( boxMin ).div( boxMax.sub( boxMin ) ); // 0..1 inside the room + + const onBack = q.z.greaterThan( 0.998 ); + const onCeil = q.y.greaterThan( 0.998 ); + const onFloor = q.y.lessThan( 0.002 ); + + // per-room key for a portable integer hash — fract( sin() ) isn't bit-exact across drivers + const cell = floor( roomCenter.mul( 2.0 ) ); // + offset before the u32 cast keeps it non-negative + const ckey = uint( cell.x.add( 1 << 21 ) ).mul( uint( 73856093 ) ) + .bitXor( uint( cell.y.add( 1 << 21 ) ).mul( uint( 19349663 ) ) ) + .bitXor( uint( cell.z.add( 1 << 21 ) ).mul( uint( 83492791 ) ) ).toVar(); + const hash = ( kx, ky, kz ) => ihash( ckey.add( uint( Math.round( ( kx + ky * 7 + kz * 13 ) * 100 ) ) ) ); + const seed = hash( 12.9898, 78.233, 37.719 ); + const seed2 = hash( 39.346, 11.135, 83.155 ); + const lit = step( 0.8, hash( 63.21, 9.17, 51.43 ) ); // ~20% of rooms have the lights on; the rest sit dark + + // each room's bulb colour. most run warm, drifting from a dim amber ( ~2400K ) up to a + // warm white ( ~3200K ); a minority run cool, from a fluorescent / LED daylight to a TV's + // bluer glow — so a lit facade reads as a spread of bulb temperatures, not one flat tint + const warmLight = mix( color( 0xffb845 ), color( 0xffe49c ), hash( 27.1, 4.9, 61.7 ) ); + const coolLight = mix( color( 0xdfe8ff ), color( 0x9fb6ff ), hash( 8.3, 51.2, 17.6 ) ); + const lightCol = select( hash( 44.7, 19.3, 6.1 ).greaterThan( 0.88 ), coolLight, warmLight ); // ~12% of lit rooms run cool + + // depth falloff ( darker toward the back ), and a panel mask on a face given its + // two 0..1 coordinates — used for the flat fittings below + const depth = roomSize.y.mul( 1.55 ); + const falloffAt = ( z ) => mix( float( 1.0 ), float( 0.42 ), z.sub( setback ).div( depth ).clamp( 0, 1 ) ); + const rect = ( ax, ay, cx, cy, hw, hh ) => smoothstep( hw + 0.006, hw - 0.006, ax.sub( cx ).abs() ).mul( smoothstep( hh + 0.006, hh - 0.006, ay.sub( cy ).abs() ) ); + + // --- the room shell: walls, floor, ceiling, back wall, with flat fittings ---- + + // muted plaster, picked per room, with a darker skirting board along the wall foot + let wall = mix( color( 0x9a8b73 ), color( 0x6f7a82 ), seed ); + wall = mix( wall, color( 0xb9ad97 ), seed2.mul( 0.6 ) ); + const wallCol = mix( wall, wall.mul( 0.5 ), smoothstep( 0.05, 0.04, q.y ) ); + + // floorboards with a thin seam every few, and a centred rug + const seam = step( 0.94, fract( q.x.mul( 6 ) ) ); + const boards = mix( color( 0x4a3320 ), color( 0x6a4c30 ), seed ).mul( seam.mul( 0.3 ).oneMinus() ); + const rug = mix( color( 0x7a3b32 ), color( 0x3a5760 ), seed2 ); + const floorCol = mix( boards, rug, rect( q.x, q.z, 0.5, 0.62, 0.3, 0.26 ).mul( 0.9 ) ); + + // ceiling, lighter than the walls, with a round overhead light in the middle; in a + // lit room the fixture reads bright and glows ( the material's emissive = colour × lit ) + const lamp = smoothstep( 0.16, 0.13, vec2( q.x.sub( 0.5 ), q.z.sub( 0.5 ) ).length() ); + const ceilCol = mix( mix( wall, color( 0xffffff ), 0.5 ), lightCol.mul( mix( float( 1.0 ), float( 4.5 ), lit ) ), lamp ); + + // back wall: a panelled door to one side, and a framed picture kept on the + // opposite half of the wall so it never lands on the door + const doorX = mix( float( 0.22 ), float( 0.78 ), seed ); + const door = mix( color( 0x5a4631 ), color( 0x39383c ), step( 0.5, seed2 ) ); + const picX = select( doorX.lessThan( 0.5 ), mix( float( 0.68 ), float( 0.82 ), seed2 ), mix( float( 0.18 ), float( 0.32 ), seed2 ) ); + const picCol = mix( color( 0x2c3a4a ), color( 0x7a5a3a ), hash( 5.1, 9.2, 3.3 ) ); + let backCol = mix( wallCol, door, rect( q.x, q.y, doorX, 0.33, 0.085, 0.35 ) ); + backCol = mix( backCol, color( 0x141210 ), rect( q.x, q.y, picX, 0.56, 0.075, 0.085 ) ); // dark frame + backCol = mix( backCol, picCol, rect( q.x, q.y, picX, 0.56, 0.055, 0.065 ) ); // the picture + + const shellCol = select( onBack, backCol, select( onCeil, ceilCol, select( onFloor, floorCol, wallCol ) ) ); + + // fake ambient occlusion: darken the hit toward the room's edges ( where two surfaces + // meet ), so the box reads with soft corner shading instead of flat-lit walls. the two + // in-plane axes depend on which face the ray exits through ( q is 0..1 inside the room ). + const aoBand = 0.15; + const aoEdge = ( a ) => smoothstep( 0, aoBand, a ).mul( smoothstep( 0, aoBand, a.oneMinus() ) ); + const edgeAO = select( onBack, aoEdge( q.x ).mul( aoEdge( q.y ) ), select( onFloor.or( onCeil ), aoEdge( q.x ).mul( aoEdge( q.z ) ), aoEdge( q.y ).mul( aoEdge( q.z ) ) ) ); + const shellAO = mix( float( 0.72 ), float( 1.0 ), edgeAO ); + + // --- nearest surface: the shell, then any furniture block that lies closer ---- + // each block is a solid axis-aligned box in room space; boxHit returns its near + // face. consider() keeps whichever surface the ray meets first. + let bestT = t; + let bestCol = shellCol.mul( shellAO ).mul( falloffAt( hit.z ) ); + let bestEmit = float( 1 ); // per-hit emissive weight: shell and fittings emit fully, curtains far less + + const boxHit = ( bMin, bMax ) => { + + const ta = bMin.sub( origin ).div( dir ); + const tb = bMax.sub( origin ).div( dir ); + const lo = ta.min( tb ), hi = ta.max( tb ); + const tN = lo.x.max( lo.y ).max( lo.z ); + const p = origin.add( dir.mul( tN ) ); + return { tN, p, hit: hi.x.min( hi.y ).min( hi.z ).greaterThan( tN ).and( tN.greaterThan( 0 ) ), qb: p.sub( bMin ).div( bMax.sub( bMin ) ) }; + + }; + + const consider = ( h, tN, c, emit = 1 ) => { + + const near = h.and( tN.lessThan( bestT ) ); bestCol = select( near, c, bestCol ); bestEmit = select( near, float( emit ), bestEmit ); bestT = select( near, tN, bestT ); + + }; + + const halfU = boxMax.x, floorY = boxMin.y, ceilY = boxMax.y, backZ = boxMax.z; + const midZ = setback.add( depth.mul( 0.5 ) ); // room centre, in depth + + // a low table near the middle of the room ( its top catches the light ) + const tCx = mix( float( - 0.6 ), float( 0.6 ), seed ); + const tCz = midZ.add( mix( float( - 0.4 ), float( 0.5 ), seed2 ) ); + const tbl = boxHit( vec3( tCx.sub( 0.6 ), floorY, tCz.sub( 0.35 ) ), vec3( tCx.add( 0.6 ), floorY.add( 0.42 ), tCz.add( 0.35 ) ) ); + const tblCol = mix( color( 0x4a3526 ), color( 0x6b4a30 ), seed2 ).mul( select( tbl.qb.y.greaterThan( 0.94 ), float( 1.25 ), float( 0.8 ) ) ); + consider( tbl.hit, tbl.tN, tblCol.mul( falloffAt( tbl.p.z ) ) ); + + // a wide low sofa against the back wall, facing the window + const sofaCx = mix( halfU.mul( - 0.3 ), halfU.mul( 0.3 ), seed2 ); + const sofa = boxHit( vec3( sofaCx.sub( 1.1 ), floorY, backZ.sub( 0.95 ) ), vec3( sofaCx.add( 1.1 ), floorY.add( mix( float( 0.8 ), float( 0.9 ), seed ) ), backZ.sub( 0.1 ) ) ); + const sofaCol = mix( color( 0x5a4a3a ), color( 0x42566a ), seed ).mul( select( sofa.qb.y.greaterThan( 0.9 ), float( 1.12 ), float( 0.85 ) ) ); + consider( sofa.hit, sofa.tN, sofaCol.mul( falloffAt( sofa.p.z ) ) ); + + // tall wardrobes in the back corners — each side stands in some rooms + const wardrobe = ( cx, gate, h ) => { + + const w = boxHit( vec3( cx.sub( 0.5 ), floorY, backZ.sub( 0.7 ) ), vec3( cx.add( 0.5 ), floorY.add( h ), backZ.sub( 0.1 ) ) ); + const c = mix( color( 0x3a2c22 ), color( 0x55473a ), seed ).mul( select( w.qb.y.greaterThan( 0.94 ), float( 1.2 ), float( 0.82 ) ) ); + consider( w.hit.and( gate ), w.tN, c.mul( falloffAt( w.p.z ) ) ); + + }; + + wardrobe( halfU.mul( - 0.82 ), hash( 7.3, 2.1, 9.9 ).greaterThan( 0.4 ), mix( float( 1.7 ), float( 2.3 ), seed ) ); + wardrobe( halfU.mul( 0.82 ), hash( 3.7, 8.4, 1.5 ).greaterThan( 0.4 ), mix( float( 1.7 ), float( 2.3 ), seed2 ) ); + + // curtains hung just inside the glass: drapes drawn part-way in from each side, + // so some windows read open and others half-covered + + // curtain fabric colour, picked per room from a muted domestic palette — creams and + // taupes through warm grey, dusty blue, sage and faded terracotta — with a small + // in-family drift so drawn drapes vary window to window instead of all reading beige + const swatch = ( a, b ) => mix( color( a ), color( b ), seed2 ); + const pick = hash( 22.4, 6.7, 91.2 ).mul( 6 ); // 0..6, one bucket per family + let fabric = swatch( 0xcabfa6, 0xd8cdb8 ); // cream + fabric = select( pick.greaterThan( 1 ), swatch( 0x8a7a64, 0x9b8c72 ), fabric ); // beige / taupe + fabric = select( pick.greaterThan( 2 ), swatch( 0x706a64, 0x837d76 ), fabric ); // warm grey + fabric = select( pick.greaterThan( 3 ), swatch( 0x5f7079, 0x6f818b ), fabric ); // dusty blue + fabric = select( pick.greaterThan( 4 ), swatch( 0x6c7558, 0x79835f ), fabric ); // sage green + fabric = select( pick.greaterThan( 5 ), swatch( 0x8c5a44, 0x9a6a52 ), fabric ); // faded terracotta + const drape = ( bMin, bMax, gate ) => { + + const h = boxHit( bMin, bMax ); + const pleat = fabric.mul( mix( float( 0.78 ), float( 1.12 ), fract( h.p.x.mul( 2.5 ) ) ) ); // soft vertical pleats + consider( h.hit.and( gate ), h.tN, pleat.mul( falloffAt( h.p.z ) ), 0.2 ); // a drape only transmits a little of the room's glow, never out-glowing the interior + + }; + + const cz0 = setback, cz1 = setback.add( 0.12 ); + // drape widths, biased narrow ( squared ) and each capped at half the room width, so + // the two sides only meet — fully curtaining the window — in the rare room where both + // are nearly closed; most rooms read partly open + const sL = smoothstep( 0.3, 1.0, seed ), sR = smoothstep( 0.3, 1.0, seed2 ); + const lw = halfU.mul( sL.mul( sL ) ); // left drape width ( 0 below seed 0.3 ) + const rw = halfU.mul( sR.mul( sR ) ); // right drape width + drape( vec3( halfU.negate(), floorY, cz0 ), vec3( halfU.negate().add( lw ), ceilY, cz1 ), lw.greaterThan( 0.05 ) ); + drape( vec3( halfU.sub( rw ), floorY, cz0 ), vec3( halfU, ceilY, cz1 ), rw.greaterThan( 0.05 ) ); + + // lit rooms read brighter and take on their bulb's colour ( the lights are on ) + const warmth = mix( vec3( 1.0, 1.0, 1.0 ), lightCol, lit.mul( 0.85 ) ); + return vec4( bestCol.mul( warmth ).mul( mix( float( 1.0 ), float( 1.3 ), lit ) ), lit.mul( bestEmit ) ); + +} ); + +/** + * The NYC masonry palette every tower is dressed from ( hex colours ): limestone-dominant + * with terracotta accents. Shared by the single-tower example and {@link CityGenerator}'s + * building material so both stay in sync. + */ +const buildingPalette = [ + 0xa8553c, 0x9c4a34, // terracotta & red brick ( occasional accent ) + 0x8a6a52, 0x7d6450, // warm brick / brownstone ( muted ) + 0xc4a370, 0xb89a6f, 0xc2b183, // buff / tan + 0xc6c0b2, 0xc6c0b2, 0xbdb7a8, 0xd1ccbe, 0xb4afa1, // limestone / pale dressed stone — the common default + 0x9a988f, 0x8b8983, 0xa5a39a, // grey granite / concrete + 0xdbd6cb, // pale glazed ( accent ) + 0x7c868d // steel / glass ( cool accent ) +]; + +/** Picks one {@link buildingPalette} colour ( a hex number ) for a tower from its seed. */ +function pickBuildingColor( seed ) { + + const h = Math.abs( Math.sin( seed * 12.9898 ) * 43758.5453 ); + return buildingPalette[ Math.floor( ( h - Math.floor( h ) ) * buildingPalette.length ) ]; + +} + +/** + * The facade material: a single MeshStandardNodeMaterial that reads the baked + * per-vertex `partId` and reproduces every zone — procedural terracotta brickwork + * on the walls and piers, smooth dressed stone on the window frames and ornament, + * dark glazing, and grey AC units — all dressed with world-space + * weathering. One material covers the whole building ( and a whole city ), which is + * what makes it compute-rasterizer friendly. `buildingBase` is the tower's flat + * masonry colour as a TSL node: pass a `uniform( Color )` for a single tower, or a + * per-fragment palette pick for a city, so the same material dresses both. + */ +function createSkyscraperMaterial( buildingBase = color( 0xc6c0b2 ) ) { + + const soot = color( 0x4a4236 ); + + // broad weathering, all driven from world position so it reads consistently + // across instanced and merged meshes: a slow tonal drift, a fine clay mottle, + // and sooty vertical streaks that pool low down + + const tone = mx_fractal_noise_float( positionWorld.mul( 0.03 ), 2 ).mul( 0.18 ); + const mottle = mx_noise_float( positionWorld.mul( 0.7 ) ).mul( 0.06 ); + const streak = mx_fractal_noise_float( vec3( positionWorld.x.mul( 1.5 ), positionWorld.y.mul( 0.04 ), positionWorld.z.mul( 1.5 ) ), 2 ); + const dirt = smoothstep( - 0.1, 0.45, streak ).mul( smoothstep( 210, 0, positionWorld.y ) ).mul( 0.6 ); + + // procedural terracotta brickwork in running bond, keyed off the BUILDING-LOCAL position + // so the coursing anchors to each tower ( courses from its base, columns at its faces ) + // and lines up with the brick-snapped floor / bay dimensions. courses run up local Y; + // the across-face axis is world XZ projected onto the face tangent, so brick width stays + // constant on every face including the 45° chamfer. the geometry ( pre-bump ) normal is + // used for the bond axis — otherwise colorNode pulls normal computation into its partId + // branch and glass loses its env reflection. + + const brickH = BRICK.height; + const brickL = BRICK.length; + const mortar = 0.025; // joint width, in metres + + const nrm = normalWorldGeometry.abs(); + const across = positionLocal.x.mul( normalWorldGeometry.z ).sub( positionLocal.z.mul( normalWorldGeometry.x ) ); + const rowCoord = positionLocal.y.div( brickH ); + const courseRow = floor( rowCoord ); + const colCoord = across.div( brickL ).add( mod( courseRow, 2 ).mul( 0.5 ) ); // half-brick stagger per row + + // anti-aliased mortar ( the "pristine grid" trick ): the drawn joint never falls below + // the pixel footprint and its opacity fades to keep energy constant, so lines stay crisp + // up close and dissolve far away instead of shimmering. the horizontal derivative comes + // from continuous world X / Z ( weighted by the normal ), not fwidth( across ) which + // would spike where the normal flips at pier edges. + const mU = mortar / ( 2 * brickL ); + const mV = mortar / ( 2 * brickH ); + const ddU = nrm.z.mul( fwidth( positionWorld.x ) ).add( nrm.x.mul( fwidth( positionWorld.z ) ) ).div( brickL ).clamp( 1e-6, 0.5 ); + const ddV = fwidth( rowCoord ).clamp( 1e-6, 0.5 ); + const distU = float( 0.5 ).sub( fract( colCoord ).sub( 0.5 ).abs() ); + const distV = float( 0.5 ).sub( fract( rowCoord ).sub( 0.5 ).abs() ); + const drawU = ddU.max( mU ); + const drawV = ddV.max( mV ); + const lineU = smoothstep( drawU.add( ddU ), drawU.sub( ddU ), distU ).mul( float( mU ).div( drawU ).min( 1 ) ); + const lineV = smoothstep( drawV.add( ddV ), drawV.sub( ddV ), distV ).mul( float( mV ).div( drawV ).min( 1 ) ); + const wallFacing = smoothstep( 0.7, 0.45, nrm.y ); // brick only on vertical walls — not roofs, ledges, cornice tops + const joint = lineU.max( lineV ).mul( wallFacing ); + + const brickKey = uint( courseRow.add( 1 << 16 ) ).mul( uint( 73856093 ) ).bitXor( uint( floor( colCoord ).add( 1 << 16 ) ).mul( uint( 19349663 ) ) ).toVar(); + const brickRnd = ihash( brickKey ); + const brickRnd2 = ihash( brickKey.add( uint( 1 ) ) ); // independent per-brick hash for hue + + // soft brick relief for the bump: each brick is a gently domed mound falling to the + // recessed mortar over a bevel ( distU / distV are the distance to the nearest column / + // course line, 0 at the joint, 0.5 at the centre ), so bricks read rounded rather than + // scratched. the bevel is widened to at least a screen pixel ( from the world-position + // derivative, our stand-in for a mip LOD ) so the edge never goes sub-pixel and shimmers. + const bevel = 0.02; + const texel = fwidth( positionWorld ).length(); // on-screen size of a surface pixel — our hand-rolled LOD + const lodBevel = texel.mul( 1.5 ).max( bevel ); + const brickFace = smoothstep( 0, lodBevel, distU.mul( brickL ) ).mul( smoothstep( 0, lodBevel, distV.mul( brickH ) ) ).mul( wallFacing ); + const reliefHeight = brickFace.mul( 0.008 ); + const rough = mx_noise_float( positionWorld.mul( 0.5 ) ).mul( 0.08 ).add( 0.82 ).add( joint.mul( 0.12 ) ); + + // the merged geometry carries a per-vertex partId; this material reads it and + // branches to reproduce each zone — no per-part materials, compute-raster friendly + + const partId = varying( attribute( 'partId', 'float' ) ).setInterpolation( InterpolationSamplingType.FLAT, InterpolationSamplingMode.EITHER ); // flat: a per-face id must not interpolate, or equal() below misses on the rounding + const isGlass = partId.equal( GLASS ); + const isFrame = partId.equal( FRAME ); + const isOrnament = partId.equal( ORNAMENT ); + const isAC = partId.equal( AC ); + + // stone zones: brick + weathering on the building's colour, lightened for + // piers / ornament and darkened for window frames + const lighten = select( partId.equal( PIER ), float( 0.12 ), select( isOrnament, float( 0.2 ), float( 0 ) ) ); + const perBrick = float( 1 ).add( tone ).add( mottle ).add( brickRnd.sub( 0.5 ).mul( 0.14 ) ); + // per-brick warm/cool shift ( red up / blue down, or vice-versa ) so individual + // bricks read as slightly different fired tones, relative to the building's colour + const warmCool = brickRnd2.sub( 0.5 ).mul( 0.14 ); + const brickShift = vec3( float( 1 ).add( warmCool ), float( 1 ), float( 1 ).sub( warmCool ) ); + const tint = mix( buildingBase, color( 0xffffff ), lighten ).mul( perBrick ).mul( brickShift ); + const masonry = mix( tint, tint.mul( 0.6 ), joint ); // recessed joints read darker + // roofs / ledges show every blotch ( flat & light ), so horizontal surfaces get a gentler, + // larger-scale grime instead of the wall's streaky soot — confined to those surfaces by a + // branch ( roofMask > 0 ), so the fractal never runs on the vertical facade + const roofMask = wallFacing.oneMinus(); + const roofGrime = select( roofMask.greaterThan( 0 ), smoothstep( 0.0, 0.55, mx_fractal_noise_float( positionWorld.mul( 0.025 ), 3 ) ).mul( 0.22 ), float( 0 ) ); + const stoneColor = mix( masonry, soot, mix( dirt, roofGrime, roofMask ) ); + + // glass: the interior-mapped room is the base colour; the smooth, low-roughness + // surface still lets a faint sky reflection ride over it, and lit rooms glow ( emissive ). + // toVar so the raymarch runs once, shared by the colour and emissive outputs + const room = interior().toVar(); + + // grimy glazing: the room shows through, but muted by a dusty film and dirt pooled + // along the bottom of each pane, plus a baseline haze, so the panes read as old + // glass rather than open holes. the streaks run down the facade ( world Y barely + // scaled ); the pooled dirt uses the pane's own UV ( y = 0 at the sill ). + const filmNoise = mx_fractal_noise_float( vec3( positionWorld.x.mul( 1.3 ), positionWorld.y.mul( 0.06 ), positionWorld.z.mul( 1.3 ) ), 2 ); + const dustStreak = smoothstep( - 0.15, 0.5, filmNoise ).mul( 0.45 ); + const pooled = smoothstep( 0.32, 0.0, uv().y ).mul( 0.4 ); + const grime = float( 0.64 ).add( dustStreak ).add( pooled ).clamp( 0, 0.95 ); // baseline haze so the panes read as dirty glass, not open holes + const dirtyGlass = mix( color( 0x13161a ), color( 0x232b31 ), mx_noise_float( positionWorld.mul( 0.3 ) ).mul( 0.5 ).add( 0.5 ) ); + const glassColor = mix( room.xyz.mul( color( 0xb6c6bf ) ), dirtyGlass, grime ); // faint green-grey ( soda-lime ) room tint, dirtied toward grimy glass + + // window frames are smooth dressed stone, not brick + const frameColor = buildingBase.mul( 0.55 ); + + // finials / ornament: smooth dressed stone ( lightened ), not brick + const ornamentColor = mix( buildingBase, color( 0xffffff ), 0.22 ).mul( float( 1 ).add( tone ) ); + // window AC units: a louvered white-plastic box, grimier toward the base where it drips. + // keyed off the box's own UVs ( acUv.y runs 0 → 1 up each vented side ) + const acUv = uv(); + const acVent = smoothstep( 0.65, 0.4, normalWorldGeometry.y.abs() ); // 1 on the vertical vented sides, 0 on the flat top + const acDetail = smoothstep( 0.08, 0.015, texel ); // louvers fade out before a slat nears a pixel + const acLouver = acVent.mul( acDetail ); + + // plastic shell: off-white, some units dingier / yellowed than others + const acDinge = mx_noise_float( positionWorld.mul( 0.4 ) ).mul( 0.5 ).add( 0.5 ); // ~per-unit + const acPaint = mix( color( 0xf2f1ec ), color( 0xcfccc2 ), acDinge ) // bright white → light dingy grey, both lighter than the wall + .add( mx_noise_float( positionWorld.mul( 5 ) ).mul( 0.04 ) ); + + // a darker recessed grille panel inset into the lighter cabinet, with horizontal louvers + // inside it ( the front vents ) — the white plastic reads as a thin border frame + const acGrille = smoothstep( 0.06, 0.14, acUv.x ).mul( smoothstep( 0.94, 0.86, acUv.x ) ) + .mul( smoothstep( 0.12, 0.2, acUv.y ) ).mul( smoothstep( 0.96, 0.88, acUv.y ) ).mul( acLouver ); + const acSlats = fract( acUv.y.mul( 6 ) ); // bold louvers — reads at the unit's small on-screen size + const acFin = mix( float( 0.82 ), float( 1.04 ), acSlats ); + const acBody = acPaint.mul( mix( float( 1 ), acFin.mul( 0.42 ), acGrille ) ); // cabinet stays light; recessed grille goes dark grey + + // grey-brown condensate grime streaking the lower edge ( plastic doesn't rust ); dirtier units streak more + const acStreak = mx_fractal_noise_float( vec3( positionWorld.x.mul( 6 ), positionWorld.y.mul( 0.5 ), positionWorld.z.mul( 6 ) ), 3 ).mul( 0.5 ).add( 0.5 ); + const acGrime = smoothstep( 0.4, 0.0, acUv.y ).mul( acStreak ).mul( acDinge.add( 0.3 ) ); + const acColor = mix( acBody, color( 0x6f685a ), acGrime.mul( 0.5 ) ); + + // recessed grille ( louver fins ) relief and a slightly rougher base + const acRelief = acGrille.mul( acSlats.mul( 0.012 ).sub( 0.01 ) ); + const acRough = float( 0.52 ).add( acGrille.mul( 0.08 ) ); + + const material = new MeshStandardNodeMaterial(); + material.colorNode = select( isGlass, glassColor, select( isFrame, frameColor, select( isOrnament, ornamentColor, select( isAC, acColor, stoneColor ) ) ) ); + material.roughnessNode = select( isGlass, float( 0.18 ), select( isOrnament, float( 0.8 ), select( isAC, acRough, rough ) ) ); // glass kept smooth for a sky reflection, but soft enough not to alias over the interior + material.metalnessNode = float( 0 ); // all dielectric — stone, glass and the plastic AC shells + material.emissiveNode = select( isGlass, room.xyz.mul( room.w ).mul( 4 ).mul( grime.mul( 0.6 ).oneMinus() ), color( 0x000000 ) ); // room.w = emissive weight ( 0 unlit, < 1 behind curtains ), muted further by grime + material.normalNode = bumpNormal( select( isGlass.or( isFrame ).or( isOrnament ), float( 0 ), select( isAC, acRelief, reliefHeight ) ) ); // glass / frames / ornament stay flat; AC has its own louvers + + return material; + +} + +export { SkyscraperGenerator, createSkyscraperMaterial, buildingPalette, pickBuildingColor }; diff --git a/examples/screenshots/webgpu_custom_fog.jpg b/examples/screenshots/webgpu_custom_fog.jpg index fe4ef73ea90393..d05b3d73af1eae 100644 Binary files a/examples/screenshots/webgpu_custom_fog.jpg and b/examples/screenshots/webgpu_custom_fog.jpg differ diff --git a/examples/screenshots/webgpu_generator_building.jpg b/examples/screenshots/webgpu_generator_building.jpg new file mode 100644 index 00000000000000..244b80594c0952 Binary files /dev/null and b/examples/screenshots/webgpu_generator_building.jpg differ diff --git a/examples/screenshots/webgpu_generator_city.jpg b/examples/screenshots/webgpu_generator_city.jpg new file mode 100644 index 00000000000000..caf2b0c6c48460 Binary files /dev/null and b/examples/screenshots/webgpu_generator_city.jpg differ diff --git a/examples/webgpu_custom_fog.html b/examples/webgpu_custom_fog.html index 04ad685e9f4ec2..4b865ec801d4b3 100644 --- a/examples/webgpu_custom_fog.html +++ b/examples/webgpu_custom_fog.html @@ -9,6 +9,9 @@ + @@ -20,7 +23,7 @@ - Custom Fog via TSL. + Custom height fog via TSL, pooling in a procedural alpine valley forested with 500,000 instanced trees. @@ -38,24 +41,67 @@ + + + + + diff --git a/examples/webgpu_generator_city.html b/examples/webgpu_generator_city.html new file mode 100644 index 00000000000000..5f5b054ca7646d --- /dev/null +++ b/examples/webgpu_generator_city.html @@ -0,0 +1,225 @@ + + + + three.js webgpu - city generator + + + + + + + + + + +
+ + +
+ three.jsCity Generator +
+ + + A few procedurally generated city blocks of Neo-Gothic terracotta skyscrapers at sunset. + +
+ + + + + + + diff --git a/test/e2e/puppeteer.js b/test/e2e/puppeteer.js index 53355c48d31cd1..1b2e4fde3b6c51 100644 --- a/test/e2e/puppeteer.js +++ b/test/e2e/puppeteer.js @@ -68,7 +68,10 @@ const exceptionList = [ // Webcam 'webgl_materials_video_webcam', - 'webgl_morphtargets_webcam' + 'webgl_morphtargets_webcam', + + // Sub-pixel coverage of thin high-contrast geometry edges differs across rasterizers #33817 + 'webgpu_generator_city' ];