SkyscraperGenerator.js 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397
  1. import {
  2. BoxGeometry,
  3. BufferAttribute,
  4. BufferGeometry,
  5. ExtrudeGeometry,
  6. InterpolationSamplingMode,
  7. InterpolationSamplingType,
  8. LatheGeometry,
  9. Matrix3,
  10. Matrix4,
  11. Mesh,
  12. MeshStandardMaterial,
  13. Path,
  14. PlaneGeometry,
  15. ShapeGeometry,
  16. Shape,
  17. Sphere,
  18. Vector2,
  19. Vector3
  20. } from 'three';
  21. import { MeshStandardNodeMaterial } from 'three/webgpu';
  22. import { attribute, cameraPosition, color, cross, dot, float, floor, Fn, fract, fwidth, hash as ihash, mix, mod, modelWorldMatrixInverse, mx_fractal_noise_float, normalLocal, normalView, normalWorldGeometry, positionLocal, positionView, positionWorld, select, smoothstep, step, uint, uv, varying, vec2, vec3, vec4 } from 'three/tsl';
  23. import { mergeGeometries } from '../../utils/BufferGeometryUtils.js';
  24. const _scale = /*@__PURE__*/ new Vector3();
  25. const _point = /*@__PURE__*/ new Vector3();
  26. const _normalMatrix = /*@__PURE__*/ new Matrix3();
  27. const _identity = /*@__PURE__*/ new Matrix4();
  28. // material-zone codes baked per vertex into the merged geometry, so one material can
  29. // branch on partId and shade every zone
  30. const PartId = { WALL: 0, PIER: 1, FRAME: 2, ORNAMENT: 3, GLASS: 4, AC: 5 };
  31. const { WALL, PIER, FRAME, ORNAMENT, GLASS, AC } = PartId;
  32. // fraction of a floor's height taken by the glazed opening; the remainder is
  33. // the spandrel band. shared by the window module and the spandrels so they tile.
  34. const WINDOW_HEIGHT_RATIO = 0.62;
  35. // width of the flat window-frame band around the glazing; shared by the frame module
  36. // and the glass pane so the pane always tucks inside the frame
  37. const WINDOW_BORDER = 0.1;
  38. // the masonry course module ( brick height × length ). the generator snaps floor and
  39. // bay dimensions to it, and the material's coursing reads the same values, so the
  40. // procedural brickwork lines up with the geometry
  41. const BRICK = { height: 0.3, length: 0.6 };
  42. // merging requires all-indexed or all-non-indexed inputs; extrusions are
  43. // non-indexed while boxes/planes are indexed, so normalize before merging
  44. function merge( geometries ) {
  45. return mergeGeometries( geometries.map( ( g ) => g.index ? g.toNonIndexed() : g ) );
  46. }
  47. function nonIndexed( geometry ) {
  48. return geometry.index ? geometry.toNonIndexed() : geometry;
  49. }
  50. // the unit box is identical for every building's shell boxes — build it once
  51. const _unitBox = /*@__PURE__*/ nonIndexed( new BoxGeometry( 1, 1, 1 ) );
  52. /**
  53. * Bakes a list of instance groups into one non-indexed BufferGeometry. Each group is a
  54. * base geometry ( position + normal + uv ), an array of Matrix4 placements and a `partId`
  55. * written to a per-vertex attribute. Transforming straight into preallocated typed arrays
  56. * avoids mergeGeometries' per-instance allocations; the result is one geometry, ready for
  57. * a single draw call and the compute rasterizer.
  58. */
  59. function bakeGroups( groups ) {
  60. let total = 0;
  61. for ( const group of groups ) total += group.geometry.attributes.position.count * group.matrices.length;
  62. const position = new Float32Array( total * 3 );
  63. const normal = new Float32Array( total * 3 );
  64. const uv = new Float32Array( total * 2 );
  65. const partId = new Float32Array( total );
  66. // per-window interior-mapping room ( centre + size ) the glass pane looks into; only
  67. // the glass group writes it, every other vertex stays zero. baked per vertex so the
  68. // material reads each building's own room sizes without a global uniform.
  69. const roomCenter = new Float32Array( total * 3 );
  70. const roomSize = new Float32Array( total * 2 );
  71. let w = 0;
  72. // the bounding sphere falls out of the AABB gathered while transforming, sparing a
  73. // second full pass over the positions ( computeBoundingSphere )
  74. let minX = Infinity, minY = Infinity, minZ = Infinity;
  75. let maxX = - Infinity, maxY = - Infinity, maxZ = - Infinity;
  76. for ( const group of groups ) {
  77. const geometry = group.geometry;
  78. const P = geometry.attributes.position.array;
  79. const N = geometry.attributes.normal.array;
  80. const U = geometry.attributes.uv.array;
  81. const count = geometry.attributes.position.count;
  82. const id = group.partId;
  83. const rooms = group.rooms; // per-instance { center, size }, glass only
  84. const rigid = group.rigid === true; // pure rotation ( + translation ): the normal matrix is the rotation itself
  85. for ( let i = 0; i < group.matrices.length; i ++ ) {
  86. const room = rooms ? rooms[ i ] : null;
  87. const matrix = group.matrices[ i ];
  88. const e = matrix.elements;
  89. 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 ];
  90. // for a rigid frame the inverse-transpose equals the rotation, so its columns
  91. // are read straight from the matrix and the per-instance 3×3 inverse is skipped
  92. let n0, n1, n2, n3, n4, n5, n6, n7, n8;
  93. if ( rigid ) {
  94. n0 = e0; n1 = e1; n2 = e2; n3 = e4; n4 = e5; n5 = e6; n6 = e8; n7 = e9; n8 = e10;
  95. } else {
  96. const ne = _normalMatrix.getNormalMatrix( matrix ).elements;
  97. 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 ];
  98. }
  99. for ( let v = 0; v < count; v ++ ) {
  100. const v3 = v * 3, w3 = w * 3;
  101. const x = P[ v3 ], y = P[ v3 + 1 ], z = P[ v3 + 2 ];
  102. const wx = e0 * x + e4 * y + e8 * z + e12;
  103. const wy = e1 * x + e5 * y + e9 * z + e13;
  104. const wz = e2 * x + e6 * y + e10 * z + e14;
  105. position[ w3 ] = wx; position[ w3 + 1 ] = wy; position[ w3 + 2 ] = wz;
  106. if ( wx < minX ) minX = wx; if ( wx > maxX ) maxX = wx;
  107. if ( wy < minY ) minY = wy; if ( wy > maxY ) maxY = wy;
  108. if ( wz < minZ ) minZ = wz; if ( wz > maxZ ) maxZ = wz;
  109. const nx = N[ v3 ], ny = N[ v3 + 1 ], nz = N[ v3 + 2 ];
  110. const tx = n0 * nx + n3 * ny + n6 * nz, ty = n1 * nx + n4 * ny + n7 * nz, tz = n2 * nx + n5 * ny + n8 * nz;
  111. const inv = 1 / ( Math.sqrt( tx * tx + ty * ty + tz * tz ) || 1 );
  112. normal[ w3 ] = tx * inv; normal[ w3 + 1 ] = ty * inv; normal[ w3 + 2 ] = tz * inv;
  113. uv[ w * 2 ] = U[ v * 2 ]; uv[ w * 2 + 1 ] = U[ v * 2 + 1 ];
  114. partId[ w ] = id;
  115. if ( room !== null ) {
  116. roomCenter[ w3 ] = room.center.x; roomCenter[ w3 + 1 ] = room.center.y; roomCenter[ w3 + 2 ] = room.center.z;
  117. roomSize[ w * 2 ] = room.size.x; roomSize[ w * 2 + 1 ] = room.size.y;
  118. }
  119. w ++;
  120. }
  121. }
  122. }
  123. const geometry = new BufferGeometry();
  124. geometry.setAttribute( 'position', new BufferAttribute( position, 3 ) );
  125. geometry.setAttribute( 'normal', new BufferAttribute( normal, 3 ) );
  126. geometry.setAttribute( 'uv', new BufferAttribute( uv, 2 ) );
  127. geometry.setAttribute( 'partId', new BufferAttribute( partId, 1 ) );
  128. geometry.setAttribute( 'roomCenter', new BufferAttribute( roomCenter, 3 ) );
  129. geometry.setAttribute( 'roomSize', new BufferAttribute( roomSize, 2 ) );
  130. geometry.boundingSphere = new Sphere(
  131. new Vector3( ( minX + maxX ) / 2, ( minY + maxY ) / 2, ( minZ + maxZ ) / 2 ),
  132. Math.hypot( maxX - minX, maxY - minY, maxZ - minZ ) / 2
  133. );
  134. return geometry;
  135. }
  136. // deterministic PRNG (mulberry32) so a given seed always yields the same tower
  137. function createRandom( seed ) {
  138. let s = ( seed >>> 0 ) || 1;
  139. return function () {
  140. s = ( s + 0x6D2B79F5 ) | 0;
  141. let t = Math.imul( s ^ ( s >>> 15 ), 1 | s );
  142. t = ( t + Math.imul( t ^ ( t >>> 7 ), 61 | t ) ) ^ t;
  143. return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296;
  144. };
  145. }
  146. // a stable per-floor hash ( from the floor index and the face origin ) used to pick the
  147. // interior-mapping room module per floor without allocating a closure each floor
  148. function floorHash( f, frame, k ) {
  149. const s = Math.sin( f * 12.9898 + frame.origin.x * 0.07 + frame.origin.z * 0.131 + k ) * 43758.5453;
  150. return s - Math.floor( s );
  151. }
  152. // the seed-driven "style" of a tower: footprint proportions, tier split and the
  153. // shaping of piers and base arches. these sit between the fixed defaults and the
  154. // caller's parameters, so any parameter passed in still overrides its seeded value.
  155. function randomStyle( random ) {
  156. const base = 0.10 + random() * 0.07;
  157. const crown = 0.08 + random() * 0.08;
  158. return {
  159. footprint: { width: 26 + random() * 18, depth: 20 + random() * 14 },
  160. tierFractions: { base, crown },
  161. pierWidth: 0.4 + random() * 0.4,
  162. pierDepth: 0.3 + random() * 0.3,
  163. windowReveal: 0.12 + random() * 0.1,
  164. stringCourseHeight: 0.5 + random() * 0.5,
  165. archBayWidthRatio: Math.round( 1.5 + random() * 1.5 ),
  166. archRise: 0.4 + random() * 0.5
  167. };
  168. }
  169. /**
  170. * Generates intricate, tripartite "Beaux-Arts / Neo-Gothic" terracotta
  171. * skyscrapers from a small set of parameters.
  172. *
  173. * The mass is read as a footprint polygon (a rectangle with one chamfered
  174. * corner) split into vertical faces, each split into three tiers — a tall
  175. * arcaded base, a repeating shaft and an ornate crown — then into floors and
  176. * bays. A handful of authored pieces (a pier, a window, a cornice profile, a
  177. * gothic arch) are instanced across the whole tower, then baked — together with
  178. * the bespoke base arcade — into a single non-indexed BufferGeometry tagged with
  179. * a per-vertex `partId` ({@link PartId}) so one material can shade every zone.
  180. *
  181. * The generator is material agnostic — it only produces geometry. Pass a single
  182. * material (e.g. a TSL node material that branches on `partId`) to dress it.
  183. *
  184. * ```js
  185. * const generator = new SkyscraperGenerator( { seed: 35, totalHeight: 140 }, material );
  186. * scene.add( generator.build() ); // a single Mesh
  187. * ```
  188. */
  189. class SkyscraperGenerator {
  190. constructor( parameters = {}, material = null ) {
  191. this.parameters = parameters; // caller overrides; defaults + seed fill the rest at build time
  192. this.material = material; // a single material; the look is driven by the baked `partId` attribute
  193. this.mesh = null;
  194. }
  195. setParameters( parameters ) {
  196. Object.assign( this.parameters, parameters );
  197. return this;
  198. }
  199. build() {
  200. const random = createRandom( this.parameters.seed ?? SkyscraperGenerator.defaults.seed );
  201. // precedence: fixed defaults < seed-driven style < caller parameters
  202. const p = Object.assign( {}, SkyscraperGenerator.defaults, randomStyle( random ), this.parameters );
  203. // snap the masonry-driving dimensions to the brick module so the procedural
  204. // brickwork ( courses up local Y, columns along each face ) lines up with the
  205. // geometry: a whole number of courses per floor and bricks per bay
  206. const vModule = BRICK.height * 2; // a course pair, so floor / window halves still land on a joint
  207. p.floorHeight = Math.max( vModule * 3, Math.round( p.floorHeight / vModule ) * vModule );
  208. p.windowHeight = Math.round( p.floorHeight * WINDOW_HEIGHT_RATIO / vModule ) * vModule;
  209. p.bayWidth = Math.max( BRICK.length * 3, Math.round( p.bayWidth / BRICK.length ) * BRICK.length );
  210. p.pierWidth = Math.max( BRICK.length, Math.round( p.pierWidth / BRICK.length ) * BRICK.length );
  211. // vertical layout: base / shaft / crown as whole floor counts, so every floor
  212. // line sits on a course ( the requested total height is rounded to suit )
  213. const floors = Math.max( 3, Math.round( p.totalHeight / p.floorHeight ) );
  214. const baseFloors = Math.max( 1, Math.round( floors * p.tierFractions.base ) );
  215. const crownFloors = Math.max( 1, Math.round( floors * p.tierFractions.crown ) );
  216. const shaftFloors = Math.max( 1, floors - baseFloors - crownFloors );
  217. const baseHeight = baseFloors * p.floorHeight;
  218. const crownHeight = crownFloors * p.floorHeight;
  219. const shaftHeight = shaftFloors * p.floorHeight;
  220. p.totalHeight = baseHeight + shaftHeight + crownHeight;
  221. const baseTop = baseHeight;
  222. const shaftTop = baseHeight + shaftHeight;
  223. // one accumulator per kind of part, mostly instance matrices. kept separate so the
  224. // bake below can order them by draw order ( which controls overdraw ), not build order.
  225. const windows = [];
  226. const glass = [];
  227. const glassRooms = []; // per-glass interior-mapping room ( centre + size ), aligned with `glass`
  228. const backWalls = []; // the thin wall closing the volume behind the glass
  229. const bands = []; // spandrel bands, one at each floor line
  230. const piers = new Map(); // pier height -> matrices, so each tier's continuous piers share one geometry
  231. const trim = []; // cornices and parapets ( axis-aligned unit boxes )
  232. const acUnits = []; // window air-conditioner boxes on a random subset of shaft windows
  233. const finials = []; // pinnacles along the crown
  234. const extras = []; // bespoke geometry: the base arcade and the setback / roof slabs
  235. const addPier = ( frame, u, vBottom, height ) => {
  236. const key = Math.round( height * 1000 ); // bucket equal pier heights ( a number key, no string )
  237. if ( piers.has( key ) === false ) piers.set( key, [] );
  238. piers.get( key ).push( frame.matrix( u, vBottom, 0 ) );
  239. };
  240. // footprints: full mass, and the inset crown after the setback
  241. const footprint = buildFootprint( p.footprint.width, p.footprint.depth, p.chamferWidth, p.chamferCornerX, p.chamferCornerZ );
  242. const faces = buildFaces( footprint );
  243. const inset = p.setbackDepth * p.bayWidth;
  244. const crownFootprint = buildFootprint(
  245. Math.max( p.bayWidth * 2, p.footprint.width - inset * 2 ),
  246. Math.max( p.bayWidth * 2, p.footprint.depth - inset * 2 ),
  247. Math.max( 0, p.chamferWidth - inset ),
  248. p.chamferCornerX,
  249. p.chamferCornerZ
  250. );
  251. const crownFaces = buildFaces( crownFootprint );
  252. // --- generate the parts -----------------------------------------------
  253. const crownCornice = p.stringCourseHeight * 1.6; // the crown's heavy cap; its piers stop below it
  254. // shaft and crown are the same facade over different faces, spans and pier heights
  255. const tiers = [
  256. { faces, bottom: baseTop, height: shaftHeight, pierHeight: shaftHeight, ac: acUnits },
  257. { faces: crownFaces, bottom: shaftTop, height: crownHeight, pierHeight: crownHeight - crownCornice, ac: null }
  258. ];
  259. for ( const t of tiers ) {
  260. for ( const frame of t.faces ) {
  261. addWindows( frame, windows, glass, glassRooms, t.ac, t.bottom, t.height, p );
  262. addWall( backWalls, frame, t.bottom, t.bottom + t.height, 0.8, - 0.6 );
  263. addSpandrelBands( bands, frame, t.bottom, t.height, p );
  264. addPiers( frame, t.bottom, t.pierHeight, p, addPier );
  265. }
  266. }
  267. // the base: a gothic arcade, capped by a string course
  268. for ( const frame of faces ) {
  269. addArcade( extras, frame, baseHeight, p );
  270. addCornice( trim, frame, baseTop - p.stringCourseHeight, p.stringCourseHeight, 0.5 );
  271. }
  272. // periodic string courses banding the shaft
  273. if ( p.stringCourseEvery > 0 ) {
  274. for ( let f = p.stringCourseEvery; f < shaftFloors; f += p.stringCourseEvery ) {
  275. for ( const frame of faces ) addCornice( trim, frame, baseTop + f * p.floorHeight - p.stringCourseHeight * 0.5, p.stringCourseHeight, 0.3 );
  276. }
  277. }
  278. // the crown's heavy cornice, its parapet and the finials along the top
  279. for ( const frame of crownFaces ) {
  280. addCornice( trim, frame, p.totalHeight - crownCornice, crownCornice, 0.9 );
  281. addParapet( trim, frame, p.totalHeight, p );
  282. addFinials( frame, finials, shaftTop, crownHeight, p );
  283. }
  284. // thin slabs capping the setback ledge and the roof
  285. extras.push( slab( footprint, shaftTop, 0.6 ) );
  286. extras.push( slab( crownFootprint, p.totalHeight, 0.6 ) );
  287. // --- bake every part into one geometry ---------------------------------
  288. // one mesh = one draw the renderer can't sort, so bake order is draw order: the
  289. // facade front-to-back, the backing wall last so its hidden fragments never shade.
  290. const groups = [
  291. { geometry: buildWindowGeometry( p ), matrices: windows, partId: FRAME, rigid: true },
  292. { geometry: nonIndexed( buildGlassGeometry( p ) ), matrices: glass, partId: GLASS, rooms: glassRooms, rigid: true },
  293. { geometry: _unitBox, matrices: bands, partId: WALL }
  294. ];
  295. for ( const [ key, matrices ] of piers ) groups.push( { geometry: buildPierGeometry( p, key / 1000 ), matrices, partId: PIER, rigid: true } );
  296. groups.push( { geometry: _unitBox, matrices: trim, partId: WALL } ); // cornices, parapets
  297. groups.push( { geometry: _unitBox, matrices: acUnits, partId: AC } );
  298. groups.push( { geometry: nonIndexed( buildFinialGeometry( p ) ), matrices: finials, partId: ORNAMENT, rigid: true } );
  299. for ( const geometry of extras ) groups.push( { geometry: nonIndexed( geometry ), matrices: [ _identity ], partId: WALL, rigid: true } ); // base arcade + slabs, in building-local space
  300. groups.push( { geometry: _unitBox, matrices: backWalls, partId: WALL } ); // last — hidden behind the facade
  301. const geometry = bakeGroups( groups );
  302. const mesh = new Mesh( geometry, this.material || new MeshStandardMaterial( { color: 0xddccaa, roughness: 0.9 } ) );
  303. mesh.name = 'Skyscraper';
  304. this.dispose();
  305. this.mesh = mesh;
  306. return mesh;
  307. }
  308. rebuild() {
  309. return this.build();
  310. }
  311. dispose() {
  312. if ( this.mesh === null ) return;
  313. this.mesh.geometry.dispose();
  314. this.mesh = null;
  315. }
  316. }
  317. // fixed baseline. the remaining parameters (footprint, tierFractions, pierWidth,
  318. // pierDepth, windowReveal, stringCourseHeight, archBayWidthRatio, archRise) are
  319. // derived from the seed by randomStyle() unless the caller provides them.
  320. SkyscraperGenerator.defaults = {
  321. seed: 35,
  322. totalHeight: 140,
  323. floorHeight: 4,
  324. bayWidth: 2.6,
  325. stringCourseEvery: 6,
  326. chamferWidth: 4,
  327. chamferCornerX: 1,
  328. chamferCornerZ: 1,
  329. setbackDepth: 1.5,
  330. acChance: 0.12
  331. };
  332. // --- footprint & faces ---------------------------------------------------
  333. /**
  334. * A rectangle (centred at the origin in the XZ plane) with one corner cut at
  335. * 45 degrees, returned as an ordered list of `Vector2( x, z )`. `cornerX` /
  336. * `cornerZ` ( each ±1 ) pick which corner is cut, so the chamfer can be aimed
  337. * outward to a block corner.
  338. */
  339. function buildFootprint( width, depth, chamfer, cornerX = 1, cornerZ = 1 ) {
  340. const hw = width / 2;
  341. const hd = depth / 2;
  342. const c = Math.min( chamfer, hw, hd );
  343. // the four corners, counter-clockwise
  344. const corners = [
  345. new Vector2( hw, hd ),
  346. new Vector2( - hw, hd ),
  347. new Vector2( - hw, - hd ),
  348. new Vector2( hw, - hd )
  349. ];
  350. const points = [];
  351. for ( let i = 0; i < corners.length; i ++ ) {
  352. const corner = corners[ i ];
  353. // cut the requested corner: replace it with two points pulled back along
  354. // each adjacent edge, leaving a 45° face that points out to that corner
  355. if ( c > 0 && Math.sign( corner.x ) === cornerX && Math.sign( corner.y ) === cornerZ ) {
  356. const prev = corners[ ( i + 3 ) % 4 ];
  357. const next = corners[ ( i + 1 ) % 4 ];
  358. points.push( corner.clone().lerp( prev, c / corner.distanceTo( prev ) ) );
  359. points.push( corner.clone().lerp( next, c / corner.distanceTo( next ) ) );
  360. } else {
  361. points.push( corner.clone() );
  362. }
  363. }
  364. return points;
  365. }
  366. /**
  367. * Builds a face frame per footprint edge. Each frame is an orthonormal basis
  368. * ( u along the edge, v up, n outward ) plus an origin and length, so all
  369. * facade layout can happen in flat ( u, v ) space and bake to world with one
  370. * matrix — the same authored piece then instances onto every face, including
  371. * the diagonal chamfer.
  372. */
  373. function buildFaces( points ) {
  374. const faces = [];
  375. const up = new Vector3( 0, 1, 0 );
  376. for ( let i = 0; i < points.length; i ++ ) {
  377. const a = points[ i ];
  378. const b = points[ ( i + 1 ) % points.length ];
  379. // outward normal: perpendicular to the edge, pointing away from the
  380. // origin (the footprint is centred there)
  381. const n = new Vector3( b.y - a.y, 0, - ( b.x - a.x ) ).normalize();
  382. const mid = new Vector3( ( a.x + b.x ) / 2, 0, ( a.y + b.y ) / 2 );
  383. if ( n.dot( mid ) < 0 ) n.negate();
  384. // right-handed basis: u = v × n, so makeBasis( u, v, n ) is a pure rotation
  385. const u = new Vector3().crossVectors( up, n ).normalize();
  386. const pa = new Vector3( a.x, 0, a.y );
  387. const pb = new Vector3( b.x, 0, b.y );
  388. const length = pa.distanceTo( pb );
  389. // the edge end that u points away from becomes the origin
  390. const origin = pb.clone().sub( pa ).dot( u ) > 0 ? pa : pb;
  391. faces.push( new FaceFrame( origin, u, up.clone(), n, length ) );
  392. }
  393. return faces;
  394. }
  395. /** A face's local ( u along edge, v up, n outward ) frame in world space. */
  396. class FaceFrame {
  397. constructor( origin, u, v, n, length ) {
  398. this.origin = origin;
  399. this.u = u;
  400. this.v = v;
  401. this.n = n;
  402. this.length = length;
  403. }
  404. point( u, v, w, target = new Vector3() ) {
  405. return target
  406. .copy( this.origin )
  407. .addScaledVector( this.u, u )
  408. .addScaledVector( this.v, v )
  409. .addScaledVector( this.n, w );
  410. }
  411. /** Places a piece authored in the canonical local frame ( x across, y up, z outward ). */
  412. matrix( u, v, w ) {
  413. return new Matrix4()
  414. .makeBasis( this.u, this.v, this.n )
  415. .setPosition( this.point( u, v, w, _point ) );
  416. }
  417. /** How many bays of `bayWidth` fit, with the remainder split into end margins. */
  418. bays( bayWidth ) {
  419. const count = Math.max( 1, Math.floor( this.length / bayWidth ) );
  420. const margin = ( this.length - count * bayWidth ) / 2;
  421. return { count, margin, width: bayWidth };
  422. }
  423. }
  424. // --- shell pieces --------------------------------------------------------
  425. // a Matrix4 mapping the shared unit box ( 1×1×1, centred ) onto a face-aligned
  426. // box of the given size, centred at the given face-local point. these matrices
  427. // are what the shell InstancedMesh is built from.
  428. function boxMatrix( frame, u, v, w, sizeU, sizeV, sizeN ) {
  429. return new Matrix4()
  430. .makeBasis( frame.u, frame.v, frame.n )
  431. .scale( _scale.set( sizeU, sizeV, sizeN ) )
  432. .setPosition( frame.point( u, v, w, _point ) );
  433. }
  434. function addWall( target, frame, vBottom, vTop, thickness = 0.8, front = 0 ) {
  435. const h = vTop - vBottom;
  436. target.push( boxMatrix( frame, frame.length / 2, vBottom + h / 2, front - thickness / 2, frame.length + thickness * 2, h, thickness ) );
  437. }
  438. /**
  439. * Horizontal terracotta bands at every floor line. Together with the projecting
  440. * piers they form the facade grid; the gaps between them are the window
  441. * openings, with glass set behind.
  442. */
  443. function addSpandrelBands( target, frame, vBottom, height, p ) {
  444. const floors = Math.max( 1, Math.round( height / p.floorHeight ) );
  445. const fh = height / floors;
  446. const bandHeight = p.floorHeight - p.windowHeight; // whole courses: floor minus the glazed opening
  447. // pull the ends in by the band depth so a band doesn't poke its end-cap
  448. // into the plane of the perpendicular face at the corners ( overdraw )
  449. const bandLength = Math.max( 0.2, frame.length - 0.6 );
  450. for ( let f = 0; f <= floors; f ++ ) {
  451. // front flush at w = 0, meeting the backing wall behind
  452. target.push( boxMatrix( frame, frame.length / 2, vBottom + f * fh, - 0.3, bandLength, bandHeight, 0.6 ) );
  453. }
  454. }
  455. /**
  456. * A thin horizontal cap over a footprint's bounding box at height `y`. Its
  457. * sides are pulled in behind the facade plane ( into the backing-wall shell )
  458. * so they never sit coplanar with the walls, spandrels or piers and z-fight.
  459. */
  460. function slab( footprint, y, thickness ) {
  461. // a thin cap following the footprint OUTLINE ( so the chamfered corner is cut, not
  462. // left overhanging as a rectangular box ), inset a little so its edge tucks just
  463. // behind the facade and the wall top reads as a lip around it
  464. const inset = 0.8;
  465. let cx = 0, cz = 0;
  466. for ( const p of footprint ) {
  467. cx += p.x; cz += p.y;
  468. }
  469. cx /= footprint.length; cz /= footprint.length;
  470. // consistent ( CCW ) winding so the extrude caps face up / down correctly
  471. let area = 0;
  472. for ( let i = 0; i < footprint.length; i ++ ) {
  473. const a = footprint[ i ], b = footprint[ ( i + 1 ) % footprint.length ];
  474. area += a.x * b.y - b.x * a.y;
  475. }
  476. const pts = area < 0 ? footprint.slice().reverse() : footprint;
  477. const shape = new Shape();
  478. pts.forEach( ( p, i ) => {
  479. const dx = cx - p.x, dz = cz - p.y;
  480. const d = Math.hypot( dx, dz ) || 1;
  481. const x = p.x + dx / d * inset;
  482. const z = p.y + dz / d * inset;
  483. if ( i === 0 ) shape.moveTo( x, z ); else shape.lineTo( x, z );
  484. } );
  485. // extrude the XZ outline downward by the thickness, the top dropped just below height y:
  486. // the inset cap would otherwise sit coplanar with the surrounding wall top faces and
  487. // z-fight, and the parapet / spandrel bands around the edge hide the shallow recess
  488. const drop = 0.2;
  489. const geometry = new ExtrudeGeometry( shape, { depth: thickness, bevelEnabled: false } );
  490. geometry.rotateX( Math.PI / 2 );
  491. geometry.translate( 0, y - drop, 0 );
  492. return geometry;
  493. }
  494. /** A two-step projecting cornice / string-course band wrapping a face. */
  495. function addCornice( target, frame, vBottom, height, depth ) {
  496. target.push( boxMatrix( frame, frame.length / 2, vBottom + height * 0.275, depth / 2, frame.length, height * 0.55, depth ) );
  497. target.push( boxMatrix( frame, frame.length / 2, vBottom + height * 0.775, depth * 0.85, frame.length, height * 0.45, depth * 1.7 ) );
  498. }
  499. /** A low parapet wall capping the crown. */
  500. function addParapet( target, frame, vTop, p ) {
  501. const height = 1.4;
  502. target.push( boxMatrix( frame, frame.length / 2, vTop + height / 2, p.pierDepth * 0.4, frame.length, height, p.pierDepth * 0.8 ) );
  503. }
  504. /**
  505. * The base storey: a wall pierced by tall pointed-arch openings, extruded with
  506. * thickness so the openings read as deep recesses.
  507. */
  508. function addArcade( target, frame, height, p ) {
  509. const archWidth = p.bayWidth * p.archBayWidthRatio;
  510. const { count, margin } = frame.bays( archWidth );
  511. const sill = height * 0.04;
  512. const spring = height * 0.55;
  513. const apex = Math.min( height * 0.96, spring + ( archWidth / 2 ) * ( 0.8 + p.archRise ) );
  514. const shape = new Shape();
  515. shape.moveTo( 0, 0 );
  516. shape.lineTo( frame.length, 0 );
  517. shape.lineTo( frame.length, height );
  518. shape.lineTo( 0, height );
  519. shape.lineTo( 0, 0 );
  520. for ( let i = 0; i < count; i ++ ) {
  521. const cx = margin + ( i + 0.5 ) * archWidth;
  522. const hw = archWidth * 0.34;
  523. const hole = new Path();
  524. hole.moveTo( cx - hw, sill );
  525. hole.lineTo( cx - hw, spring );
  526. hole.quadraticCurveTo( cx - hw, apex, cx, apex );
  527. hole.quadraticCurveTo( cx + hw, apex, cx + hw, spring );
  528. hole.lineTo( cx + hw, sill );
  529. hole.lineTo( cx - hw, sill );
  530. shape.holes.push( hole );
  531. }
  532. const thickness = 1.1;
  533. const geometry = new ExtrudeGeometry( shape, { depth: thickness, bevelEnabled: false, curveSegments: 8 } );
  534. geometry.translate( 0, 0, - thickness );
  535. geometry.applyMatrix4( frame.matrix( 0, 0, 0 ) );
  536. target.push( geometry );
  537. // a dark plane set behind the openings so the recesses read
  538. const back = new PlaneGeometry( frame.length, height );
  539. back.applyMatrix4( frame.matrix( frame.length / 2, height / 2, - thickness - 0.4 ) );
  540. target.push( back );
  541. }
  542. // --- repeating field -----------------------------------------------------
  543. function addPiers( frame, vBottom, height, p, addPier ) {
  544. const { count, margin, width } = frame.bays( p.bayWidth );
  545. // a pier on every bay edge except the far end: that corner is shared with
  546. // the next face, which places its own pier there, so emitting both would
  547. // stack two piers at each corner
  548. for ( let i = 0; i < count; i ++ ) {
  549. addPier( frame, margin + i * width, vBottom, height );
  550. }
  551. }
  552. function addWindows( frame, windows, glass, glassRooms, acUnits, vBottom, height, p ) {
  553. const { count, margin, width } = frame.bays( p.bayWidth );
  554. const floors = Math.max( 1, Math.round( height / p.floorHeight ) );
  555. const fh = height / floors;
  556. // a window AC unit sitting on the sill, protruding from the facade. about half the window
  557. // width, capped at a real unit's size ( ~0.66 m ) and kept wider than tall, sticking out
  558. // about half its width
  559. const acW = Math.min( ( p.bayWidth - p.pierWidth ) * 0.55, 0.66 );
  560. const acH = acW * 0.6;
  561. const acD = acW * 0.5;
  562. const acV = - p.windowHeight / 2 + acH / 2 + WINDOW_BORDER; // bottom rests on the sill ( the top of the window's bottom frame rail )
  563. // a real ~0.66 m unit looks lost in a wide opening, so only fit ACs where it still spans a
  564. // fair share of the window — in practice, the narrower ( older-style ) windows
  565. const acFits = acW >= ( width - p.pierWidth ) * 0.34;
  566. for ( let f = 0; f < floors; f ++ ) {
  567. const cy = vBottom + ( f + 0.5 ) * fh;
  568. // the interior-mapping room module: one floor tall, a run of two or three bays
  569. // wide, chosen per floor so neighbouring windows share an interior. the choice
  570. // is deterministic ( seeded by the floor and the face ) so it is stable, and the
  571. // run is recorded per window so the material can ray-march the right box.
  572. const roomBays = floorHash( f, frame, 0 ) > 0.5 ? 3 : 2;
  573. const roomPhase = Math.floor( floorHash( f, frame, 1 ) * roomBays );
  574. for ( let b = 0; b < count; b ++ ) {
  575. const cx = margin + ( b + 0.5 ) * width;
  576. windows.push( frame.matrix( cx, cy, 0 ) );
  577. glass.push( frame.matrix( cx, cy, - p.windowReveal ) );
  578. // the run of bays this window's room spans, clamped at the face ends, recorded
  579. // as the room's centre on the facade and its width × height in metres
  580. const room = Math.floor( ( b + roomPhase ) / roomBays );
  581. const bStart = Math.max( 0, room * roomBays - roomPhase );
  582. const bEnd = Math.min( count, ( room + 1 ) * roomBays - roomPhase );
  583. const span = bEnd - bStart;
  584. 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
  585. if ( acUnits && acFits ) {
  586. // deterministic per-window hash ( varies per face via the frame origin )
  587. const r = Math.sin( f * 41.3 + b * 12.7 + frame.origin.x * 0.13 + frame.origin.z * 0.31 ) * 43758.5453;
  588. // the back tucks into the window reveal ( just in front of the glass ) so the unit sits
  589. // in the opening instead of floating on the facade
  590. const acW0 = acD / 2 - p.windowReveal + 0.04;
  591. if ( r - Math.floor( r ) < p.acChance ) acUnits.push( boxMatrix( frame, cx, cy + acV, acW0, acW, acH, acD ) );
  592. }
  593. }
  594. }
  595. }
  596. function addFinials( frame, finials, vBottom, height, p ) {
  597. const { count, margin, width } = frame.bays( p.bayWidth );
  598. const top = vBottom + height;
  599. // skip the far-end bay edge: it is the shared corner the next face also
  600. // caps, so emitting both would stack two finials at each corner
  601. for ( let i = 0; i < count; i ++ ) {
  602. finials.push( new Matrix4().setPosition( frame.point( margin + i * width, top, p.pierDepth * 0.5, _point ) ) );
  603. }
  604. }
  605. // --- authored modules ----------------------------------------------------
  606. function buildPierGeometry( p, height ) {
  607. // a wide pier with a slimmer pilaster raised on its face, giving the
  608. // continuous vertical rib a stepped, terracotta profile
  609. const back = new BoxGeometry( p.pierWidth, height, p.pierDepth * 0.6 );
  610. back.translate( 0, height / 2, p.pierDepth * 0.3 );
  611. // the pilaster stops just short of the pier top so that where a pier is left
  612. // exposed ( at a setback ) the cap reads as one clean block rather than the
  613. // back box and the pilaster stacked into a T
  614. const pilasterHeight = Math.max( 1, height - 0.6 );
  615. const front = new BoxGeometry( p.pierWidth * 0.55, pilasterHeight, p.pierDepth * 0.45 );
  616. front.translate( 0, pilasterHeight / 2, p.pierDepth * 0.6 + p.pierDepth * 0.225 );
  617. return merge( [ back, front ] );
  618. }
  619. function buildWindowGeometry( p ) {
  620. // the flat frame face ( a rectangle with the glazing hole ), the four reveal walls
  621. // of the opening and the glazing bars, merged into one instanced module. a full
  622. // extrusion would also emit a hidden back cap and outer side walls; windows are by
  623. // far the heaviest part of a building, so those are skipped.
  624. const w = p.bayWidth - p.pierWidth;
  625. const h = p.windowHeight;
  626. const border = WINDOW_BORDER;
  627. 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
  628. const iw = w / 2 - border;
  629. const ih = h / 2 - border;
  630. const shape = new Shape();
  631. shape.moveTo( - w / 2, - h / 2 );
  632. shape.lineTo( w / 2, - h / 2 );
  633. shape.lineTo( w / 2, h / 2 );
  634. shape.lineTo( - w / 2, h / 2 );
  635. shape.lineTo( - w / 2, - h / 2 );
  636. const hole = new Path();
  637. hole.moveTo( - iw, - ih );
  638. hole.lineTo( - iw, ih );
  639. hole.lineTo( iw, ih );
  640. hole.lineTo( iw, - ih );
  641. hole.lineTo( - iw, - ih );
  642. shape.holes.push( hole );
  643. const front = new ShapeGeometry( shape ); // visible frame face, flush with the facade
  644. // the four reveal walls of the opening, set back to the glazing
  645. const wall = ( x, y, rx, ry, sw, sh ) => {
  646. const pl = new PlaneGeometry( sw, sh );
  647. pl.rotateX( rx );
  648. pl.rotateY( ry );
  649. pl.translate( x, y, - depth / 2 );
  650. return pl;
  651. };
  652. const left = wall( - iw, 0, 0, Math.PI / 2, depth, ih * 2 );
  653. const right = wall( iw, 0, 0, - Math.PI / 2, depth, ih * 2 );
  654. const sill = wall( 0, - ih, - Math.PI / 2, 0, iw * 2, depth );
  655. const head = wall( 0, ih, Math.PI / 2, 0, iw * 2, depth );
  656. // a single horizontal glazing bar ( transom ), flat, just in front of the glass —
  657. // a thin box would triple the window's triangle count for sub-pixel thickness
  658. const transom = new PlaneGeometry( iw * 2, 0.05 );
  659. transom.translate( 0, h * 0.04, - depth + 0.02 ); // meeting rail, just above centre
  660. return merge( [ front, left, right, sill, head, transom ] );
  661. }
  662. function buildGlassGeometry( p ) {
  663. const w = p.bayWidth - p.pierWidth - WINDOW_BORDER * 2;
  664. const h = p.windowHeight - WINDOW_BORDER * 2;
  665. return new PlaneGeometry( w, h );
  666. }
  667. function buildFinialGeometry( p ) {
  668. // a tapering pinnacle revolved around its axis
  669. const s = p.pierWidth;
  670. const profile = [
  671. new Vector2( 0.0, 0 ),
  672. new Vector2( s * 0.9, 0 ),
  673. new Vector2( s * 0.9, s * 0.4 ),
  674. new Vector2( s * 0.55, s * 1.0 ),
  675. new Vector2( 0.0, s * 3.2 )
  676. ];
  677. return new LatheGeometry( profile, 8 ); // round enough to read as a smooth pinnacle, still light
  678. }
  679. // --- material ------------------------------------------------------------
  680. // derivative-based bump for a procedural, world-space height field. the built-in bumpMap
  681. // offsets the UV to read its height, so it returns a zero gradient for a height keyed off
  682. // world position; this feeds the hardware screen-space derivatives of the height into
  683. // Mikkelsen's surface-gradient method so the relief actually perturbs the normal.
  684. function bumpNormal( height ) {
  685. const dpdx = positionView.dFdx();
  686. const dpdy = positionView.dFdy();
  687. const r1 = dpdy.cross( normalView );
  688. const r2 = normalView.cross( dpdx );
  689. const det = dpdx.dot( r1 );
  690. const grad = det.sign().mul( height.dFdx().mul( r1 ).add( height.dFdy().mul( r2 ) ) );
  691. return det.abs().mul( normalView ).sub( grad ).normalize();
  692. }
  693. // interior mapping: fakes a furnished room behind each glass pane in the fragment
  694. // shader — no geometry, no texture. every pane carries the room it looks into ( centre +
  695. // size, baked per window by addWindows ), so neighbouring panes share one interior. the
  696. // view ray is cast into that box and the walls, floor, ceiling and a few furniture pieces
  697. // it meets are shaded procedurally, keyed off a per-room hash. returns vec4( colour, lit ).
  698. const interior = /*@__PURE__*/ Fn( () => {
  699. // flat so floor() below can't split one pane across two cell ids ( centre is per-room )
  700. const roomCenter = varying( attribute( 'roomCenter', 'vec3' ) ).setInterpolation( InterpolationSamplingType.FLAT, InterpolationSamplingMode.EITHER );
  701. const roomSize = attribute( 'roomSize', 'vec2' );
  702. // a per-face frame from the geometry normal ( holds on every facade, including the
  703. // 45° chamfer ): u runs across the face, v is up, n points outward
  704. const n = normalLocal;
  705. const up = vec3( 0, 1, 0 );
  706. const uAxis = cross( up, n ).normalize();
  707. // this pixel and the view ray, in the room's ( across, up, depth ) frame; depth
  708. // runs into the wall, so the ray's depth component is positive
  709. const d = positionLocal.sub( roomCenter );
  710. const camLocal = modelWorldMatrixInverse.mul( vec4( cameraPosition, 1 ) ).xyz;
  711. const rayLocal = positionLocal.sub( camLocal ).normalize();
  712. const origin = vec3( dot( d, uAxis ), d.y, 0 );
  713. const dir = vec3( dot( rayLocal, uAxis ), rayLocal.y, dot( rayLocal, n ).negate() );
  714. // the room box: the pane-wide × ceiling-height front rectangle ( centred on the pane ),
  715. // set back behind the glass and run a little deeper than it is tall. shade the far
  716. // side the ray exits ( slab method: nearest of the three far-plane crossings;
  717. // dividing by a near-zero direction gives ±inf, which min() harmlessly drops ).
  718. const setback = float( 0.1 ); // the room starts just behind the glass, so it sits flush in the frame opening
  719. const boxMax = vec3( roomSize.x.mul( 0.5 ), roomSize.y.mul( 0.5 ), setback.add( roomSize.y.mul( 1.55 ) ) );
  720. const boxMin = vec3( boxMax.x.negate(), boxMax.y.negate(), setback );
  721. const tFar = boxMin.sub( origin ).div( dir ).max( boxMax.sub( origin ).div( dir ) );
  722. const t = tFar.x.min( tFar.y ).min( tFar.z );
  723. const hit = origin.add( dir.mul( t ) );
  724. const q = hit.sub( boxMin ).div( boxMax.sub( boxMin ) ); // 0..1 inside the room
  725. const onBack = q.z.greaterThan( 0.998 );
  726. const onCeil = q.y.greaterThan( 0.998 );
  727. const onFloor = q.y.lessThan( 0.002 );
  728. // per-room key for a portable integer hash — fract( sin() ) isn't bit-exact across drivers
  729. const cell = floor( roomCenter.mul( 2.0 ) ); // + offset before the u32 cast keeps it non-negative
  730. const ckey = uint( cell.x.add( 1 << 21 ) ).mul( uint( 73856093 ) )
  731. .bitXor( uint( cell.y.add( 1 << 21 ) ).mul( uint( 19349663 ) ) )
  732. .bitXor( uint( cell.z.add( 1 << 21 ) ).mul( uint( 83492791 ) ) ).toVar();
  733. const hash = ( kx, ky, kz ) => ihash( ckey.add( uint( Math.round( ( kx + ky * 7 + kz * 13 ) * 100 ) ) ) );
  734. const seed = hash( 12.9898, 78.233, 37.719 );
  735. const seed2 = hash( 39.346, 11.135, 83.155 );
  736. const lit = step( 0.8, hash( 63.21, 9.17, 51.43 ) ); // ~20% of rooms have the lights on; the rest sit dark
  737. // each room's bulb colour. most run warm, drifting from a dim amber ( ~2400K ) up to a
  738. // warm white ( ~3200K ); a minority run cool, from a fluorescent / LED daylight to a TV's
  739. // bluer glow — so a lit facade reads as a spread of bulb temperatures, not one flat tint
  740. const warmLight = mix( color( 0xffb845 ), color( 0xffe49c ), hash( 27.1, 4.9, 61.7 ) );
  741. const coolLight = mix( color( 0xdfe8ff ), color( 0x9fb6ff ), hash( 8.3, 51.2, 17.6 ) );
  742. const lightCol = select( hash( 44.7, 19.3, 6.1 ).greaterThan( 0.88 ), coolLight, warmLight ); // ~12% of lit rooms run cool
  743. // depth falloff ( darker toward the back ), and a panel mask on a face given its
  744. // two 0..1 coordinates — used for the flat fittings below
  745. const depth = roomSize.y.mul( 1.55 );
  746. const falloffAt = ( z ) => mix( float( 1.0 ), float( 0.42 ), z.sub( setback ).div( depth ).clamp( 0, 1 ) );
  747. 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() ) );
  748. // --- the room shell: walls, floor, ceiling, back wall, with flat fittings ----
  749. // muted plaster, picked per room, with a darker skirting board along the wall foot
  750. let wall = mix( color( 0x9a8b73 ), color( 0x6f7a82 ), seed );
  751. wall = mix( wall, color( 0xb9ad97 ), seed2.mul( 0.6 ) );
  752. const wallCol = mix( wall, wall.mul( 0.5 ), smoothstep( 0.05, 0.04, q.y ) );
  753. // floorboards with a thin seam every few, and a centred rug
  754. const seam = step( 0.94, fract( q.x.mul( 6 ) ) );
  755. const boards = mix( color( 0x4a3320 ), color( 0x6a4c30 ), seed ).mul( seam.mul( 0.3 ).oneMinus() );
  756. const rug = mix( color( 0x7a3b32 ), color( 0x3a5760 ), seed2 );
  757. const floorCol = mix( boards, rug, rect( q.x, q.z, 0.5, 0.62, 0.3, 0.26 ).mul( 0.9 ) );
  758. // ceiling, lighter than the walls, with a round overhead light in the middle; in a
  759. // lit room the fixture reads bright and glows ( the material's emissive = colour × lit )
  760. const lamp = smoothstep( 0.16, 0.13, vec2( q.x.sub( 0.5 ), q.z.sub( 0.5 ) ).length() );
  761. const ceilCol = mix( mix( wall, color( 0xffffff ), 0.5 ), lightCol.mul( mix( float( 1.0 ), float( 4.5 ), lit ) ), lamp );
  762. // back wall: a panelled door to one side, and a framed picture kept on the
  763. // opposite half of the wall so it never lands on the door
  764. const doorX = mix( float( 0.22 ), float( 0.78 ), seed );
  765. const door = mix( color( 0x5a4631 ), color( 0x39383c ), step( 0.5, seed2 ) );
  766. const picX = select( doorX.lessThan( 0.5 ), mix( float( 0.68 ), float( 0.82 ), seed2 ), mix( float( 0.18 ), float( 0.32 ), seed2 ) );
  767. const picCol = mix( color( 0x2c3a4a ), color( 0x7a5a3a ), hash( 5.1, 9.2, 3.3 ) );
  768. let backCol = mix( wallCol, door, rect( q.x, q.y, doorX, 0.33, 0.085, 0.35 ) );
  769. backCol = mix( backCol, color( 0x141210 ), rect( q.x, q.y, picX, 0.56, 0.075, 0.085 ) ); // dark frame
  770. backCol = mix( backCol, picCol, rect( q.x, q.y, picX, 0.56, 0.055, 0.065 ) ); // the picture
  771. const shellCol = select( onBack, backCol, select( onCeil, ceilCol, select( onFloor, floorCol, wallCol ) ) );
  772. // fake ambient occlusion: darken the hit toward the room's edges ( where two surfaces
  773. // meet ), so the box reads with soft corner shading instead of flat-lit walls. the two
  774. // in-plane axes depend on which face the ray exits through ( q is 0..1 inside the room ).
  775. const aoBand = 0.15;
  776. const aoEdge = ( a ) => smoothstep( 0, aoBand, a ).mul( smoothstep( 0, aoBand, a.oneMinus() ) );
  777. 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 ) ) ) );
  778. const shellAO = mix( float( 0.72 ), float( 1.0 ), edgeAO );
  779. // --- nearest surface: the shell, then any furniture block that lies closer ----
  780. // each block is a solid axis-aligned box in room space; boxHit returns its near
  781. // face. consider() keeps whichever surface the ray meets first.
  782. let bestT = t;
  783. let bestCol = shellCol.mul( shellAO ).mul( falloffAt( hit.z ) );
  784. let bestEmit = float( 1 ); // per-hit emissive weight: shell and fittings emit fully, curtains far less
  785. const boxHit = ( bMin, bMax ) => {
  786. const ta = bMin.sub( origin ).div( dir );
  787. const tb = bMax.sub( origin ).div( dir );
  788. const lo = ta.min( tb ), hi = ta.max( tb );
  789. const tN = lo.x.max( lo.y ).max( lo.z );
  790. const p = origin.add( dir.mul( tN ) );
  791. 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 ) ) };
  792. };
  793. const consider = ( h, tN, c, emit = 1 ) => {
  794. const near = h.and( tN.lessThan( bestT ) ); bestCol = select( near, c, bestCol ); bestEmit = select( near, float( emit ), bestEmit ); bestT = select( near, tN, bestT );
  795. };
  796. const halfU = boxMax.x, floorY = boxMin.y, ceilY = boxMax.y, backZ = boxMax.z;
  797. const midZ = setback.add( depth.mul( 0.5 ) ); // room centre, in depth
  798. // a low table near the middle of the room ( its top catches the light )
  799. const tCx = mix( float( - 0.6 ), float( 0.6 ), seed );
  800. const tCz = midZ.add( mix( float( - 0.4 ), float( 0.5 ), seed2 ) );
  801. 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 ) ) );
  802. const tblCol = mix( color( 0x4a3526 ), color( 0x6b4a30 ), seed2 ).mul( select( tbl.qb.y.greaterThan( 0.94 ), float( 1.25 ), float( 0.8 ) ) );
  803. consider( tbl.hit, tbl.tN, tblCol.mul( falloffAt( tbl.p.z ) ) );
  804. // a wide low sofa against the back wall, facing the window
  805. const sofaCx = mix( halfU.mul( - 0.3 ), halfU.mul( 0.3 ), seed2 );
  806. 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 ) ) );
  807. const sofaCol = mix( color( 0x5a4a3a ), color( 0x42566a ), seed ).mul( select( sofa.qb.y.greaterThan( 0.9 ), float( 1.12 ), float( 0.85 ) ) );
  808. consider( sofa.hit, sofa.tN, sofaCol.mul( falloffAt( sofa.p.z ) ) );
  809. // tall wardrobes in the back corners — each side stands in some rooms
  810. const wardrobe = ( cx, gate, h ) => {
  811. 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 ) ) );
  812. const c = mix( color( 0x3a2c22 ), color( 0x55473a ), seed ).mul( select( w.qb.y.greaterThan( 0.94 ), float( 1.2 ), float( 0.82 ) ) );
  813. consider( w.hit.and( gate ), w.tN, c.mul( falloffAt( w.p.z ) ) );
  814. };
  815. wardrobe( halfU.mul( - 0.82 ), hash( 7.3, 2.1, 9.9 ).greaterThan( 0.4 ), mix( float( 1.7 ), float( 2.3 ), seed ) );
  816. wardrobe( halfU.mul( 0.82 ), hash( 3.7, 8.4, 1.5 ).greaterThan( 0.4 ), mix( float( 1.7 ), float( 2.3 ), seed2 ) );
  817. // curtains hung just inside the glass: drapes drawn part-way in from each side,
  818. // so some windows read open and others half-covered
  819. // curtain fabric colour, picked per room from a muted domestic palette — creams and
  820. // taupes through warm grey, dusty blue, sage and faded terracotta — with a small
  821. // in-family drift so drawn drapes vary window to window instead of all reading beige
  822. const swatch = ( a, b ) => mix( color( a ), color( b ), seed2 );
  823. const pick = hash( 22.4, 6.7, 91.2 ).mul( 6 ); // 0..6, one bucket per family
  824. let fabric = swatch( 0xcabfa6, 0xd8cdb8 ); // cream
  825. fabric = select( pick.greaterThan( 1 ), swatch( 0x8a7a64, 0x9b8c72 ), fabric ); // beige / taupe
  826. fabric = select( pick.greaterThan( 2 ), swatch( 0x706a64, 0x837d76 ), fabric ); // warm grey
  827. fabric = select( pick.greaterThan( 3 ), swatch( 0x5f7079, 0x6f818b ), fabric ); // dusty blue
  828. fabric = select( pick.greaterThan( 4 ), swatch( 0x6c7558, 0x79835f ), fabric ); // sage green
  829. fabric = select( pick.greaterThan( 5 ), swatch( 0x8c5a44, 0x9a6a52 ), fabric ); // faded terracotta
  830. const drape = ( bMin, bMax, gate ) => {
  831. const h = boxHit( bMin, bMax );
  832. const pleat = fabric.mul( mix( float( 0.78 ), float( 1.12 ), fract( h.p.x.mul( 2.5 ) ) ) ); // soft vertical pleats
  833. 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
  834. };
  835. const cz0 = setback, cz1 = setback.add( 0.12 );
  836. // drape widths, biased narrow ( squared ) and each capped at half the room width, so
  837. // the two sides only meet — fully curtaining the window — in the rare room where both
  838. // are nearly closed; most rooms read partly open
  839. const sL = smoothstep( 0.3, 1.0, seed ), sR = smoothstep( 0.3, 1.0, seed2 );
  840. const lw = halfU.mul( sL.mul( sL ) ); // left drape width ( 0 below seed 0.3 )
  841. const rw = halfU.mul( sR.mul( sR ) ); // right drape width
  842. drape( vec3( halfU.negate(), floorY, cz0 ), vec3( halfU.negate().add( lw ), ceilY, cz1 ), lw.greaterThan( 0.05 ) );
  843. drape( vec3( halfU.sub( rw ), floorY, cz0 ), vec3( halfU, ceilY, cz1 ), rw.greaterThan( 0.05 ) );
  844. // lit rooms read brighter and take on their bulb's colour ( the lights are on )
  845. const warmth = mix( vec3( 1.0, 1.0, 1.0 ), lightCol, lit.mul( 0.85 ) );
  846. return vec4( bestCol.mul( warmth ).mul( mix( float( 1.0 ), float( 1.3 ), lit ) ), lit.mul( bestEmit ) );
  847. } );
  848. /**
  849. * The NYC masonry palette every tower is dressed from ( hex colours ): limestone-dominant
  850. * with terracotta accents. Shared by the single-tower example and {@link CityGenerator}'s
  851. * building material so both stay in sync.
  852. */
  853. const buildingPalette = [
  854. 0xa8553c, 0x9c4a34, // terracotta & red brick ( occasional accent )
  855. 0x8a6a52, 0x7d6450, // warm brick / brownstone ( muted )
  856. 0xc4a370, 0xb89a6f, 0xc2b183, // buff / tan
  857. 0xc6c0b2, 0xc6c0b2, 0xbdb7a8, 0xd1ccbe, 0xb4afa1, // limestone / pale dressed stone — the common default
  858. 0x9a988f, 0x8b8983, 0xa5a39a, // grey granite / concrete
  859. 0xdbd6cb, // pale glazed ( accent )
  860. 0x7c868d // steel / glass ( cool accent )
  861. ];
  862. /** Picks one {@link buildingPalette} colour ( a hex number ) for a tower from its seed. */
  863. function pickBuildingColor( seed ) {
  864. const h = Math.abs( Math.sin( seed * 12.9898 ) * 43758.5453 );
  865. return buildingPalette[ Math.floor( ( h - Math.floor( h ) ) * buildingPalette.length ) ];
  866. }
  867. // cheap value noise ( ~[ -1, 1 ] ), a lighter stand-in for gradient mx_noise on the
  868. // weathering terms; integer-hashed ( not fract(sin) ) to stay stable across drivers
  869. const valueNoise = /*@__PURE__*/ Fn( ( [ p ] ) => {
  870. const i = floor( p );
  871. const f = fract( p );
  872. const u = f.mul( f ).mul( f.mul( - 2 ).add( 3 ) ); // 3f2 - 2f3 smooth interpolation
  873. const corner = ( ox, oy, oz ) => {
  874. const c = i.add( vec3( ox, oy, oz ) );
  875. return ihash( uint( c.x.add( 1 << 20 ) ).mul( uint( 73856093 ) ).bitXor( uint( c.y.add( 1 << 20 ) ).mul( uint( 19349663 ) ) ).bitXor( uint( c.z.add( 1 << 20 ) ).mul( uint( 83492791 ) ) ) );
  876. };
  877. const x00 = mix( corner( 0, 0, 0 ), corner( 1, 0, 0 ), u.x );
  878. const x10 = mix( corner( 0, 1, 0 ), corner( 1, 1, 0 ), u.x );
  879. const x01 = mix( corner( 0, 0, 1 ), corner( 1, 0, 1 ), u.x );
  880. const x11 = mix( corner( 0, 1, 1 ), corner( 1, 1, 1 ), u.x );
  881. return mix( mix( x00, x10, u.y ), mix( x01, x11, u.y ), u.z ).mul( 2 ).sub( 1 );
  882. } ).setLayout( { name: 'valueNoise', type: 'float', inputs: [ { name: 'p', type: 'vec3' } ] } );
  883. // fractal ( fBm ) of valueNoise, octaves summed like mx_fractal_noise_float
  884. // ( amplitude halving, frequency doubling ); unrolled for a compile-time count
  885. const valueFractal = ( p, octaves ) => {
  886. let sum = valueNoise( p );
  887. let amp = 0.5, freq = 2;
  888. for ( let o = 1; o < octaves; o ++ ) {
  889. sum = sum.add( valueNoise( p.mul( freq ) ).mul( amp ) );
  890. amp *= 0.5;
  891. freq *= 2;
  892. }
  893. return sum;
  894. };
  895. /**
  896. * The facade material: a single MeshStandardNodeMaterial that reads the baked
  897. * per-vertex `partId` and reproduces every zone — procedural terracotta brickwork
  898. * on the walls and piers, smooth dressed stone on the window frames and ornament,
  899. * dark glazing, and grey AC units — all dressed with world-space
  900. * weathering. One material covers the whole building ( and a whole city ), which is
  901. * what makes it compute-rasterizer friendly. `buildingBase` is the tower's flat
  902. * masonry colour as a TSL node: pass a `uniform( Color )` for a single tower, or a
  903. * per-fragment palette pick for a city, so the same material dresses both.
  904. */
  905. function createSkyscraperMaterial( buildingBase = color( 0xc6c0b2 ) ) {
  906. const soot = color( 0x4a4236 );
  907. // broad weathering, all driven from world position so it reads consistently
  908. // across instanced and merged meshes: a slow tonal drift, a fine clay mottle,
  909. // and sooty vertical streaks that pool low down
  910. const tone = varying( mx_fractal_noise_float( positionWorld.mul( 0.03 ), 2 ) ).mul( 0.18 ); // very low frequency: evaluate per-vertex and interpolate over the facade's fine tessellation
  911. const mottle = valueNoise( positionWorld.mul( 0.7 ) ).mul( 0.06 );
  912. const streak = mx_fractal_noise_float( vec3( positionWorld.x.mul( 1.5 ), positionWorld.y.mul( 0.04 ), positionWorld.z.mul( 1.5 ) ), 2 );
  913. const dirt = smoothstep( - 0.1, 0.45, streak ).mul( smoothstep( 210, 0, positionWorld.y ) ).mul( 0.6 );
  914. // procedural terracotta brickwork in running bond, keyed off the BUILDING-LOCAL position
  915. // so the coursing anchors to each tower ( courses from its base, columns at its faces )
  916. // and lines up with the brick-snapped floor / bay dimensions. courses run up local Y;
  917. // the across-face axis is world XZ projected onto the face tangent, so brick width stays
  918. // constant on every face including the 45° chamfer. the geometry ( pre-bump ) normal is
  919. // used for the bond axis — otherwise colorNode pulls normal computation into its partId
  920. // branch and glass loses its env reflection.
  921. const brickH = BRICK.height;
  922. const brickL = BRICK.length;
  923. const mortar = 0.025; // joint width, in metres
  924. const nrm = normalWorldGeometry.abs();
  925. const across = positionLocal.x.mul( normalWorldGeometry.z ).sub( positionLocal.z.mul( normalWorldGeometry.x ) );
  926. const rowCoord = positionLocal.y.div( brickH );
  927. const courseRow = floor( rowCoord );
  928. const colCoord = across.div( brickL ).add( mod( courseRow, 2 ).mul( 0.5 ) ); // half-brick stagger per row
  929. // anti-aliased mortar ( the "pristine grid" trick ): the drawn joint never falls below
  930. // the pixel footprint and its opacity fades to keep energy constant, so lines stay crisp
  931. // up close and dissolve far away instead of shimmering. the horizontal derivative comes
  932. // from continuous world X / Z ( weighted by the normal ), not fwidth( across ) which
  933. // would spike where the normal flips at pier edges.
  934. const mU = mortar / ( 2 * brickL );
  935. const mV = mortar / ( 2 * brickH );
  936. const ddU = nrm.z.mul( fwidth( positionWorld.x ) ).add( nrm.x.mul( fwidth( positionWorld.z ) ) ).div( brickL ).clamp( 1e-6, 0.5 );
  937. const ddV = fwidth( rowCoord ).clamp( 1e-6, 0.5 );
  938. const distU = float( 0.5 ).sub( fract( colCoord ).sub( 0.5 ).abs() );
  939. const distV = float( 0.5 ).sub( fract( rowCoord ).sub( 0.5 ).abs() );
  940. const drawU = ddU.max( mU );
  941. const drawV = ddV.max( mV );
  942. const lineU = smoothstep( drawU.add( ddU ), drawU.sub( ddU ), distU ).mul( float( mU ).div( drawU ).min( 1 ) );
  943. const lineV = smoothstep( drawV.add( ddV ), drawV.sub( ddV ), distV ).mul( float( mV ).div( drawV ).min( 1 ) );
  944. const wallFacing = smoothstep( 0.7, 0.45, nrm.y ); // brick only on vertical walls — not roofs, ledges, cornice tops
  945. const joint = lineU.max( lineV ).mul( wallFacing );
  946. const brickKey = uint( courseRow.add( 1 << 16 ) ).mul( uint( 73856093 ) ).bitXor( uint( floor( colCoord ).add( 1 << 16 ) ).mul( uint( 19349663 ) ) ).toVar();
  947. const brickRnd = ihash( brickKey );
  948. const brickRnd2 = ihash( brickKey.add( uint( 1 ) ) ); // independent per-brick hash for hue
  949. // soft brick relief for the bump: each brick is a gently domed mound falling to the
  950. // recessed mortar over a bevel ( distU / distV are the distance to the nearest column /
  951. // course line, 0 at the joint, 0.5 at the centre ), so bricks read rounded rather than
  952. // scratched. the bevel is widened to at least a screen pixel ( from the world-position
  953. // derivative, our stand-in for a mip LOD ) so the edge never goes sub-pixel and shimmers.
  954. const bevel = 0.02;
  955. const texel = fwidth( positionWorld ).length(); // on-screen size of a surface pixel — our hand-rolled LOD
  956. const lodBevel = texel.mul( 1.5 ).max( bevel );
  957. const brickFace = smoothstep( 0, lodBevel, distU.mul( brickL ) ).mul( smoothstep( 0, lodBevel, distV.mul( brickH ) ) ).mul( wallFacing );
  958. const reliefHeight = brickFace.mul( 0.008 );
  959. const rough = valueNoise( positionWorld.mul( 0.5 ) ).mul( 0.08 ).add( 0.82 ).add( joint.mul( 0.12 ) );
  960. // the merged geometry carries a per-vertex partId; this material reads it and
  961. // branches to reproduce each zone — no per-part materials, compute-raster friendly
  962. 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
  963. const isGlass = partId.equal( GLASS );
  964. const isFrame = partId.equal( FRAME );
  965. const isOrnament = partId.equal( ORNAMENT );
  966. const isAC = partId.equal( AC );
  967. // stone zones: brick + weathering on the building's colour, lightened for
  968. // piers / ornament and darkened for window frames
  969. const lighten = select( partId.equal( PIER ), float( 0.12 ), select( isOrnament, float( 0.2 ), float( 0 ) ) );
  970. const perBrick = float( 1 ).add( tone ).add( mottle ).add( brickRnd.sub( 0.5 ).mul( 0.14 ) );
  971. // per-brick warm/cool shift ( red up / blue down, or vice-versa ) so individual
  972. // bricks read as slightly different fired tones, relative to the building's colour
  973. const warmCool = brickRnd2.sub( 0.5 ).mul( 0.14 );
  974. const brickShift = vec3( float( 1 ).add( warmCool ), float( 1 ), float( 1 ).sub( warmCool ) );
  975. const tint = mix( buildingBase, color( 0xffffff ), lighten ).mul( perBrick ).mul( brickShift );
  976. const masonry = mix( tint, tint.mul( 0.6 ), joint ); // recessed joints read darker
  977. // roofs / ledges show every blotch ( flat & light ), so horizontal surfaces get a gentler,
  978. // larger-scale grime instead of the wall's streaky soot — confined to those surfaces by a
  979. // branch ( roofMask > 0 ), so the fractal never runs on the vertical facade
  980. const roofMask = wallFacing.oneMinus();
  981. const roofGrime = select( roofMask.greaterThan( 0 ), smoothstep( 0.0, 0.55, valueFractal( positionWorld.mul( 0.025 ), 3 ) ).mul( 0.22 ), float( 0 ) );
  982. const stoneColor = mix( masonry, soot, mix( dirt, roofGrime, roofMask ) );
  983. // glass: the interior-mapped room is the base colour; the smooth, low-roughness
  984. // surface still lets a faint sky reflection ride over it, and lit rooms glow ( emissive ).
  985. // toVar so the raymarch runs once, shared by the colour and emissive outputs
  986. const room = interior().toVar();
  987. // grimy glazing: the room shows through, but muted by a dusty film and dirt pooled
  988. // along the bottom of each pane, plus a baseline haze, so the panes read as old
  989. // glass rather than open holes. the streaks run down the facade ( world Y barely
  990. // scaled ); the pooled dirt uses the pane's own UV ( y = 0 at the sill ).
  991. const filmNoise = mx_fractal_noise_float( vec3( positionWorld.x.mul( 1.3 ), positionWorld.y.mul( 0.06 ), positionWorld.z.mul( 1.3 ) ), 2 );
  992. const dustStreak = smoothstep( - 0.15, 0.5, filmNoise ).mul( 0.45 );
  993. const pooled = smoothstep( 0.32, 0.0, uv().y ).mul( 0.4 );
  994. 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
  995. const dirtyGlass = mix( color( 0x13161a ), color( 0x232b31 ), valueFractal( positionWorld.mul( 0.3 ), 2 ).mul( 0.5 ).add( 0.5 ) );
  996. const glassColor = mix( room.xyz.mul( color( 0xb6c6bf ) ), dirtyGlass, grime ); // faint green-grey ( soda-lime ) room tint, dirtied toward grimy glass
  997. // window frames are smooth dressed stone, not brick
  998. const frameColor = buildingBase.mul( 0.55 );
  999. // finials / ornament: smooth dressed stone ( lightened ), not brick
  1000. const ornamentColor = mix( buildingBase, color( 0xffffff ), 0.22 ).mul( float( 1 ).add( tone ) );
  1001. // window AC units: a louvered white-plastic box, grimier toward the base where it drips.
  1002. // keyed off the box's own UVs ( acUv.y runs 0 → 1 up each vented side )
  1003. const acUv = uv();
  1004. const acVent = smoothstep( 0.65, 0.4, normalWorldGeometry.y.abs() ); // 1 on the vertical vented sides, 0 on the flat top
  1005. const acDetail = smoothstep( 0.08, 0.015, texel ); // louvers fade out before a slat nears a pixel
  1006. const acLouver = acVent.mul( acDetail );
  1007. // plastic shell: off-white, some units dingier / yellowed than others
  1008. const acDinge = valueNoise( positionWorld.mul( 0.4 ) ).mul( 0.5 ).add( 0.5 ); // ~per-unit
  1009. const acPaint = mix( color( 0xf2f1ec ), color( 0xcfccc2 ), acDinge ) // bright white → light dingy grey, both lighter than the wall
  1010. .add( valueNoise( positionWorld.mul( 5 ) ).mul( 0.04 ) );
  1011. // a darker recessed grille panel inset into the lighter cabinet, with horizontal louvers
  1012. // inside it ( the front vents ) — the white plastic reads as a thin border frame
  1013. const acGrille = smoothstep( 0.06, 0.14, acUv.x ).mul( smoothstep( 0.94, 0.86, acUv.x ) )
  1014. .mul( smoothstep( 0.12, 0.2, acUv.y ) ).mul( smoothstep( 0.96, 0.88, acUv.y ) ).mul( acLouver );
  1015. const acSlats = fract( acUv.y.mul( 6 ) ); // bold louvers — reads at the unit's small on-screen size
  1016. const acFin = mix( float( 0.82 ), float( 1.04 ), acSlats );
  1017. const acBody = acPaint.mul( mix( float( 1 ), acFin.mul( 0.42 ), acGrille ) ); // cabinet stays light; recessed grille goes dark grey
  1018. // grey-brown condensate grime streaking the lower edge ( plastic doesn't rust ); dirtier units streak more
  1019. const acStreak = valueFractal( vec3( positionWorld.x.mul( 6 ), positionWorld.y.mul( 0.5 ), positionWorld.z.mul( 6 ) ), 3 ).mul( 0.5 ).add( 0.5 );
  1020. const acGrime = smoothstep( 0.4, 0.0, acUv.y ).mul( acStreak ).mul( acDinge.add( 0.3 ) );
  1021. const acColor = mix( acBody, color( 0x6f685a ), acGrime.mul( 0.5 ) );
  1022. // recessed grille ( louver fins ) relief and a slightly rougher base
  1023. const acRelief = acGrille.mul( acSlats.mul( 0.012 ).sub( 0.01 ) );
  1024. const acRough = float( 0.52 ).add( acGrille.mul( 0.08 ) );
  1025. const material = new MeshStandardNodeMaterial();
  1026. material.colorNode = select( isGlass, glassColor, select( isFrame, frameColor, select( isOrnament, ornamentColor, select( isAC, acColor, stoneColor ) ) ) );
  1027. 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
  1028. material.metalnessNode = float( 0 ); // all dielectric — stone, glass and the plastic AC shells
  1029. 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
  1030. 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
  1031. return material;
  1032. }
  1033. export { SkyscraperGenerator, createSkyscraperMaterial, buildingPalette, pickBuildingColor };
粤ICP备19079148号