/* global AFRAME, THREE */ // The world: a balcony high up a glass tower, over a big city on a grey, // foggy day. The city is plain boxes, snapped to 3.5 m floors and bays and // painted with two window textures (concrete and glass), merged into one mesh // per texture; every balcony up and down the tower is one instanced unit, and // only ours has furniture. The fog (worlds.json) and two drifting layers of // cloud, one well below the balcony and one not far above, hide how simple it // all is: the street is out of sight, the tallest towers vanish into the // overcast, and red aircraft lights blink on the roofs around. // // The balcony floor is at y = 0 and its middle at the origin; -z is out over // the city, +z the glass doors back into the flat. (function () { const SEED = 2112; const BALCONY = { halfX: 3.4, front: -1.4, back: 1.3, slab: 0.25, rail: 1.08 }; const STOREY = 3.5; // floor to floor, and the width of a bay const GROUND = -140; // the street, 40 storeys down const TILE = STOREY * 8; // the window textures are 8 bays × 8 storeys // Our tower: its front is the balcony's back wall; its top is in the clouds. const TOWER = { halfX: 16, depth: 32, top: 70 }; const COLUMNS = [-11, 0, 11]; // balconies across the tower's face const FLOORS = { below: 38, above: 10 }; const BLOCK = 45; // city blocks, with streets between const STREET = 15; const REACH = 6; // blocks out from ours in each direction const CLOUDS = { below: -58, above: 38, size: 1800 }; const BLINK = 1.6; // seconds per aircraft-light flash cycle const MARGIN = 0.25; // how close your head gets to the glass function mulberry32(seed) { return function () { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // --- painted textures -------------------------------------------------------- function canvasTexture(width, height, paint, { color = true } = {}) { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; paint(canvas.getContext('2d'), width, height); const texture = new THREE.CanvasTexture(canvas); if (color) texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = 8; texture.wrapS = texture.wrapT = THREE.RepeatWrapping; return texture; } // A facade texture and its glow: 8 × 8 cells, one per bay and storey, with // some windows lit. paintCell(ctx, x, y, size, lit) paints one cell; the // glow gets the lit windows only. The top-left pixels are always plain wall, // which is what roofs use. function facade(rand, litShare, paintCell, paintGlow) { const size = 64; const lit = []; for (let i = 0; i < 64; i++) lit.push(rand() < litShare); const map = canvasTexture(512, 512, (ctx) => { for (let i = 0; i < 64; i++) paintCell(ctx, (i % 8) * size, Math.floor(i / 8) * size, size, lit[i]); }); const glow = canvasTexture(512, 512, (ctx) => { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, 512, 512); for (let i = 0; i < 64; i++) if (lit[i]) paintGlow(ctx, (i % 8) * size, Math.floor(i / 8) * size, size); }); return { map, glow }; } // Punched windows in a pale wall (tinted per building). function concreteFacade(rand) { const win = (x, y) => [x + 12, y + 14, 40, 38]; return facade(rand, 0.16, (ctx, x, y, s, lit) => { ctx.fillStyle = '#fff'; ctx.fillRect(x, y, s, s); ctx.fillStyle = 'rgba(0,0,0,0.06)'; ctx.fillRect(x, y + s - 6, s, 6); // a ledge under each storey const [wx, wy, ww, wh] = win(x, y); if (lit) { ctx.fillStyle = '#ead9a6'; } else { const g = ctx.createLinearGradient(0, wy, 0, wy + wh); g.addColorStop(0, '#5d6770'); g.addColorStop(1, '#3c444b'); ctx.fillStyle = g; } ctx.fillRect(wx, wy, ww, wh); ctx.fillStyle = '#d9d9d9'; ctx.fillRect(wx + ww / 2 - 1, wy, 2, wh); // the middle frame }, (ctx, x, y) => { const [wx, wy, ww, wh] = win(x, y); ctx.fillStyle = '#8a7650'; ctx.fillRect(wx, wy, ww, wh); }); } // A curtain wall: panes reflecting the grey sky, thin mullions, a dark band // at each floor. function glassFacade(rand) { return facade(rand, 0.1, (ctx, x, y, s, lit) => { const l = 52 + rand() * 10; ctx.fillStyle = lit ? '#d9cba0' : `hsl(205,12%,${l}%)`; ctx.fillRect(x, y, s, s); if (!lit) { ctx.fillStyle = 'rgba(255,255,255,0.08)'; ctx.fillRect(x, y, s, s * 0.45); } ctx.fillStyle = '#4c555c'; ctx.fillRect(x, y + s - 9, s, 9); ctx.fillStyle = '#c9ced1'; ctx.fillRect(x, y, 4, s); ctx.fillRect(x + s / 2 - 1, y, 2, s - 9); }, (ctx, x, y, s) => { ctx.fillStyle = '#6e6244'; ctx.fillRect(x + 4, y, s - 4, s - 9); }); } // Grey concrete pavers, 60 cm square, four to a tile. function pavers(rand) { return canvasTexture(256, 256, (ctx, w, h) => { const s = w / 2; for (let i = 0; i < 4; i++) { const x = (i % 2) * s; const y = Math.floor(i / 2) * s; ctx.fillStyle = `hsl(35,4%,${62 + rand() * 7}%)`; ctx.fillRect(x, y, s, s); for (let k = 0; k < 220; k++) { ctx.fillStyle = `rgba(${rand() < 0.5 ? '0,0,0' : '255,255,255'},${0.03 + rand() * 0.05})`; ctx.fillRect(x + rand() * s, y + rand() * s, 1 + rand() * 3, 1 + rand() * 3); } ctx.fillStyle = 'rgba(30,30,30,0.55)'; ctx.fillRect(x, y, s, 2); ctx.fillRect(x, y, 2, s); } }); } // Soft cloud, tileable: blobs drawn again across each edge. function cloudTexture(rand, base) { return canvasTexture(256, 256, (ctx, w, h) => { ctx.fillStyle = `rgba(255,255,255,${base})`; ctx.fillRect(0, 0, w, h); for (let i = 0; i < 70; i++) { const x = rand() * w; const y = rand() * h; const r = 18 + rand() * 60; const a = 0.08 + rand() * 0.22; for (const dx of [-w, 0, w]) { for (const dy of [-h, 0, h]) { const g = ctx.createRadialGradient(x + dx, y + dy, 0, x + dx, y + dy, r); g.addColorStop(0, `rgba(255,255,255,${a})`); g.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = g; ctx.fillRect(x + dx - r, y + dy - r, r * 2, r * 2); } } } }); } // --- merged boxes -------------------------------------------------------------- // Collects boxes into one geometry. Their sides take facade UVs in metres // (÷ TILE) from a corner of the building, so windows line up with storeys; // their tops all sample the textures' plain top-left corner. Bottoms are // left out unless asked for, since nobody sees under a building. class Boxes { constructor() { this.position = []; this.normal = []; this.uv = []; this.color = []; this.index = []; } quad(corner, u, v, n, uv, color) { const i = this.position.length / 3; const pts = [corner, corner.clone().add(u), corner.clone().add(u).add(v), corner.clone().add(v)]; for (let k = 0; k < 4; k++) { this.position.push(pts[k].x, pts[k].y, pts[k].z); this.normal.push(n.x, n.y, n.z); this.uv.push(uv[k * 2], uv[k * 2 + 1]); this.color.push(color.r, color.g, color.b); } this.index.push(i, i + 1, i + 2, i, i + 2, i + 3); } // b: { x0, x1, y0, y1, z0, z1 }; u0/v0: texture offset of the building's // corner (in tiles), or null for plain wall all round; base: the // building's ground, where v starts; bottom: whether to close it underneath. box(b, color, roofColor, u0 = 0, v0 = 0, base = b.y0, bottom = false) { const V = THREE.Vector3; const w = b.x1 - b.x0; const d = b.z1 - b.z0; const h = b.y1 - b.y0; const p = 0.004; const plain = [p, 1 - p, p, 1 - p, p, 1 - p, p, 1 - p]; const vb = v0 + (b.y0 - base) / TILE; const vt = vb + h / TILE; const side = (corner, u, n, len, offset) => { const ua = u0 + offset / TILE; const ub = ua + len / TILE; this.quad(corner, u, new V(0, h, 0), n, u0 === null ? plain : [ua, vb, ub, vb, ub, vt, ua, vt], color); }; // Round the building: front (-z), right (+x), back (+z), left (-x). side(new V(b.x1, b.y0, b.z0), new V(-w, 0, 0), new V(0, 0, -1), w, 0); side(new V(b.x0, b.y0, b.z0), new V(0, 0, d), new V(-1, 0, 0), d, w); side(new V(b.x0, b.y0, b.z1), new V(w, 0, 0), new V(0, 0, 1), w, w + d); side(new V(b.x1, b.y0, b.z1), new V(0, 0, -d), new V(1, 0, 0), d, 2 * w + d); this.quad(new V(b.x0, b.y1, b.z1), new V(w, 0, 0), new V(0, 0, -d), new V(0, 1, 0), plain, roofColor); if (bottom) this.quad(new V(b.x0, b.y0, b.z0), new V(w, 0, 0), new V(0, 0, d), new V(0, -1, 0), plain, roofColor); } geometry() { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(this.position, 3)); g.setAttribute('normal', new THREE.Float32BufferAttribute(this.normal, 3)); g.setAttribute('uv', new THREE.Float32BufferAttribute(this.uv, 2)); g.setAttribute('color', new THREE.Float32BufferAttribute(this.color, 3)); g.setIndex(this.index); g.computeBoundingSphere(); return g; } } // An untextured box, for the balconies and furniture, closed all round: // you see the undersides of the balconies above. function plainBox(boxes, x0, x1, y0, y1, z0, z1, color) { boxes.box({ x0, x1, y0, y1, z0, z1 }, color, color, null, 0, y0, true); } AFRAME.registerComponent('balcony', { schema: { windSound: { default: '' }, }, init() { this.rand = mulberry32(SEED); this.group = new THREE.Group(); this.el.setObject3D('mesh', this.group); this.disposables = []; this.spawn = { x: 0.6, z: 0.5, yaw: 0 }; // facing the city this.screen = { x: 0, y: 1.9, z: BALCONY.front - 4, yaw: 0, width: 3.2 }; // out over the street // Two chairs at a little table on the left, a bench on the right. this.seats = [ { x: -2.55, y: 0.45, z: -0.35, yaw: -0.25 }, { x: -0.95, y: 0.45, z: -0.35, yaw: 0.25 }, { x: 1.45, y: 0.45, z: 0.6, yaw: 0 }, { x: 2.45, y: 0.45, z: 0.6, yaw: 0 }, ]; this.buildSky(); this.buildCity(); this.buildBalconies(); this.buildFurniture(); this.buildClouds(); if (this.data.windSound) { const sound = document.createElement('a-entity'); sound.setAttribute('sound', { src: `url(${this.data.windSound})`, autoplay: true, loop: true, volume: 0.35, positional: false }); this.el.appendChild(sound); } }, track(...things) { this.disposables.push(...things); return things[0]; }, // A grey dome, a little brighter overhead, the fog's colour from the // horizon down so the fogged city melts into it. buildSky() { const fog = this.el.sceneEl.object3D.fog; const horizon = fog ? fog.color.clone() : new THREE.Color('#a3a9ae'); const zenith = new THREE.Color('#c4c8cc'); const geometry = this.track(new THREE.SphereGeometry(1500, 16, 10)); const pos = geometry.attributes.position; const colors = []; const c = new THREE.Color(); for (let i = 0; i < pos.count; i++) { const up = Math.max(0, pos.getY(i) / 1500); c.copy(horizon).lerp(zenith, Math.pow(up, 0.6)); colors.push(c.r, c.g, c.b); } geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); const material = this.track(new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.BackSide, fog: false, depthWrite: false })); const dome = new THREE.Mesh(geometry, material); dome.renderOrder = -1; this.group.add(dome); }, // Blocks on a grid round ours, cut into one to four lots, each a tower: // a box, sometimes a narrower box on top, and a plant room on the roof. buildCity() { const r = this.rand; const concrete = new Boxes(); const glass = new Boxes(); const lights = []; const pitch = BLOCK + STREET; // Our block's front edge is the balcony's back wall. const originZ = BALCONY.back + BLOCK / 2; const snap = (v) => Math.max(STOREY, Math.round(v / STOREY) * STOREY); const tints = { concrete: ['#dcd7ce', '#cdc7bb', '#bcc0c3', '#aea79d', '#d2cabf', '#a3a8ac', '#c2b6a6'], glass: ['#e4ecf2', '#d3dce2', '#c4ced6', '#eef0f0', '#b9c6cf'], }; const tint = (list, k) => new THREE.Color(list[Math.floor(r() * list.length)]).multiplyScalar(k); const tower = (x0, x1, z0, z1, height) => { const isGlass = r() < 0.38; const boxes = isGlass ? glass : concrete; const color = tint(isGlass ? tints.glass : tints.concrete, 0.92 + r() * 0.08); const roof = color.clone().multiplyScalar(0.62); const u0 = Math.floor(r() * 8) / 8; const v0 = Math.floor(r() * 8) / 8; const top = GROUND + height; // A setback: the top third or so stands back from the edges. const w = x1 - x0; const d = z1 - z0; if (height > 60 && w > 14 && d > 14 && r() < 0.5) { const lower = GROUND + snap(height * (0.55 + r() * 0.2)); boxes.box({ x0, x1, y0: GROUND, y1: lower, z0, z1 }, color, roof, u0, v0); const ix = snap(w * 0.18); const iz = snap(d * 0.18); x0 += ix; x1 -= ix; z0 += iz; z1 -= iz; boxes.box({ x0, x1, y0: lower, y1: top, z0, z1 }, color, roof, u0, v0, GROUND); } else { boxes.box({ x0, x1, y0: GROUND, y1: top, z0, z1 }, color, roof, u0, v0); } // The plant room, plain wall all round. const pw = Math.min(x1 - x0, 8) * (0.4 + r() * 0.3); const pd = Math.min(z1 - z0, 8) * (0.4 + r() * 0.3); const px = x0 + (x1 - x0 - pw) * r(); const pz = z0 + (z1 - z0 - pd) * r(); const ph = 2.5 + r() * 3; concrete.box({ x0: px, x1: px + pw, y0: top, y1: top + ph, z0: pz, z1: pz + pd }, roof, roof, null); // Aircraft lights on the corners of the tall ones still under the cloud. if (top > CLOUDS.below + 4 && top < CLOUDS.above - 4) { for (const [x, z] of [[x0, z0], [x1, z0], [x0, z1], [x1, z1]]) lights.push(new THREE.Vector3(x, top + 0.6, z)); } }; for (let i = -REACH; i <= REACH; i++) { for (let j = -REACH; j <= REACH; j++) { const cx = i * pitch; const cz = originZ + j * pitch; if (i === 0 && j === 0) continue; // ours const distance = Math.hypot(cx, cz); if (distance > (REACH + 0.5) * pitch) continue; // round the city off // Mostly below us, so there's a view; some up to our height, a few // up into the cloud. In front, close by, keep them low. const roll = r(); let height = roll < 0.55 ? 30 + r() * 80 : roll < 0.88 ? 110 + r() * 70 : 190 + r() * 150; const ahead = cz < 0 && Math.abs(cx) < 100; if (ahead && distance < 150) height = Math.min(height, -GROUND - 25 - r() * 30); // Cut the block into lots. const split = r(); const lots = []; const h = BLOCK / 2; if (split < 0.3) lots.push([cx - h, cx + h, cz - h, cz + h]); else if (split < 0.55) lots.push([cx - h, cx, cz - h, cz + h], [cx, cx + h, cz - h, cz + h]); else if (split < 0.75) lots.push([cx - h, cx + h, cz - h, cz], [cx - h, cx + h, cz, cz + h]); else for (const [a, b] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) lots.push([cx + Math.min(0, a * h), cx + Math.max(0, a * h), cz + Math.min(0, b * h), cz + Math.max(0, b * h)]); for (const [x0, x1, z0, z1] of lots) { // Each lot's tower stands in from its edges by whole bays (or // more), and varies around the block's height. const w = snap((x1 - x0) - 2 - r() * 8); const d = snap((z1 - z0) - 2 - r() * 8); const mx = (x0 + x1) / 2 + (r() - 0.5) * ((x1 - x0) - w); const mz = (z0 + z1) / 2 + (r() - 0.5) * ((z1 - z0) - d); const lotHeight = snap(Math.max(20, height * (0.6 + r() * 0.5))); const capped = ahead && distance < 150 ? Math.min(lotHeight, snap(height)) : lotHeight; tower(mx - w / 2, mx + w / 2, mz - d / 2, mz + d / 2, capped); } } } // Our own tower: glass, with the balconies on its face. glass.box({ x0: -TOWER.halfX, x1: TOWER.halfX, y0: GROUND, y1: TOWER.top, z0: BALCONY.back, z1: BALCONY.back + TOWER.depth }, new THREE.Color('#dde5ea'), new THREE.Color('#888'), 0, 0); const concreteTex = concreteFacade(r); const glassTex = glassFacade(r); for (const [boxes, tex] of [[concrete, concreteTex], [glass, glassTex]]) { const material = this.track(new THREE.MeshLambertMaterial({ map: tex.map, emissiveMap: tex.glow, emissive: '#ffffff', emissiveIntensity: 0.8, vertexColors: true, }), tex.map, tex.glow); this.group.add(new THREE.Mesh(this.track(boxes.geometry()), material)); } // The street, far down in the murk. const ground = new THREE.Mesh( this.track(new THREE.PlaneGeometry(3000, 3000)), this.track(new THREE.MeshLambertMaterial({ color: '#55595d' })), ); ground.rotation.x = -Math.PI / 2; ground.position.y = GROUND; this.group.add(ground); // The aircraft lights all flash together. this.lightMaterial = this.track(new THREE.MeshBasicMaterial({ color: '#ff2a1a' })); const lamp = new THREE.InstancedMesh(this.track(new THREE.OctahedronGeometry(0.7, 0)), this.lightMaterial, lights.length); const m = new THREE.Matrix4(); lights.forEach((p, k) => lamp.setMatrixAt(k, m.makeTranslation(p.x, p.y, p.z))); lamp.computeBoundingSphere(); this.group.add(lamp); }, // One balcony (slab, posts and rails, then its glass) instanced up and // down the tower's face; ours is the one at floor 0 in the middle column. buildBalconies() { const B = BALCONY; const steel = new THREE.Color('#3b4045'); const slab = new THREE.Color('#c9c7c2'); const unit = new Boxes(); plainBox(unit, -B.halfX, B.halfX, -B.slab, 0, B.front, B.back, slab); const post = 0.05; for (const x of [-B.halfX, B.halfX]) { plainBox(unit, x - post / 2, x + post / 2, 0, B.rail, B.front, B.front + post, steel); plainBox(unit, x - post / 2, x + post / 2, B.rail - 0.05, B.rail, B.front, B.back, steel); // side rails } for (const x of [-1.15, 1.15]) plainBox(unit, x - post / 2, x + post / 2, 0, B.rail, B.front, B.front + post, steel); plainBox(unit, -B.halfX, B.halfX, B.rail - 0.05, B.rail + 0.01, B.front - 0.01, B.front + 0.06, steel); // top rail // The glass doors' frame across the back. for (const x of [-B.halfX, -1.15, 1.15, B.halfX]) plainBox(unit, x - 0.04, x + 0.04, 0, STOREY - B.slab, B.back - 0.08, B.back, steel); plainBox(unit, -B.halfX, B.halfX, STOREY - B.slab - 0.08, STOREY - B.slab, B.back - 0.08, B.back, steel); plainBox(unit, -B.halfX, B.halfX, 0, 0.06, B.back - 0.08, B.back, steel); const panes = new Boxes(); const white = new THREE.Color('#ffffff'); const g = 0.012; plainBox(panes, -B.halfX, B.halfX, 0.04, B.rail - 0.06, B.front + 0.02, B.front + 0.02 + g, white); for (const x of [-B.halfX, B.halfX]) plainBox(panes, x - g / 2, x + g / 2, 0.04, B.rail - 0.06, B.front + post, B.back - 0.08, white); const opaque = this.track(new THREE.MeshLambertMaterial({ vertexColors: true })); const glassMat = this.track(new THREE.MeshLambertMaterial({ color: '#a9c2c9', transparent: true, opacity: 0.22, depthWrite: false })); const count = COLUMNS.length * (FLOORS.below + FLOORS.above + 1); const units = new THREE.InstancedMesh(this.track(unit.geometry()), opaque, count); const glass = new THREE.InstancedMesh(this.track(panes.geometry()), glassMat, count); glass.renderOrder = 1; const m = new THREE.Matrix4(); let k = 0; for (const x of COLUMNS) { for (let f = -FLOORS.below; f <= FLOORS.above; f++) { m.makeTranslation(x, f * STOREY, 0); units.setMatrixAt(k, m); glass.setMatrixAt(k, m); k++; } } units.computeBoundingSphere(); glass.computeBoundingSphere(); this.group.add(units, glass); // Pavers on our own floor. const floorMap = this.track(pavers(this.rand)); floorMap.repeat.set(B.halfX * 2 / 1.2, (B.back - B.front) / 1.2); const floor = new THREE.Mesh( this.track(new THREE.PlaneGeometry(B.halfX * 2, B.back - B.front)), this.track(new THREE.MeshLambertMaterial({ map: floorMap })), ); floor.rotation.x = -Math.PI / 2; floor.position.set(0, 0.002, (B.front + B.back) / 2); this.group.add(floor); // Behind our own doors, a dim room rather than the tower's facade. const inside = new THREE.Mesh( this.track(new THREE.PlaneGeometry(B.halfX * 2, STOREY - B.slab)), this.track(new THREE.MeshLambertMaterial({ color: '#2e3439', emissive: '#3a2f22', emissiveIntensity: 0.35 })), ); inside.position.set(0, (STOREY - B.slab) / 2, B.back - 0.01); inside.rotation.y = Math.PI; this.group.add(inside); }, // Two chairs at a round table, a bench, and planters in the front corners. buildFurniture() { const r = this.rand; const parts = new Boxes(); const wood = new THREE.Color('#9c7650'); const steel = new THREE.Color('#33383c'); const leg = 0.035; const chair = (x, z, yaw) => { const o = new THREE.Group(); o.position.set(x, 0, z); o.rotation.y = yaw; const b = new Boxes(); plainBox(b, -0.24, 0.24, 0.42, 0.46, -0.22, 0.22, wood); // seat plainBox(b, -0.24, 0.24, 0.5, 0.9, 0.2, 0.24, wood); // back for (const [lx, lz] of [[-0.21, -0.19], [0.21, -0.19], [-0.21, 0.21], [0.21, 0.21]]) { plainBox(b, lx - leg / 2, lx + leg / 2, 0, lz > 0 ? 0.9 : 0.42, lz - leg / 2, lz + leg / 2, steel); } o.add(new THREE.Mesh(this.track(b.geometry()), this.furnitureMat)); this.group.add(o); }; this.furnitureMat = this.track(new THREE.MeshLambertMaterial({ vertexColors: true })); for (const seat of this.seats.slice(0, 2)) chair(seat.x, seat.z + 0.05, seat.yaw); // The table between them. const tableTop = new THREE.Mesh(this.track(new THREE.CylinderGeometry(0.34, 0.34, 0.03, 12)), this.track(new THREE.MeshLambertMaterial({ color: '#8a6a48' }))); tableTop.position.set(-1.75, 0.68, -0.55); const stem = new THREE.Mesh(this.track(new THREE.CylinderGeometry(0.03, 0.03, 0.67, 6)), this.track(new THREE.MeshLambertMaterial({ color: '#33383c' }))); stem.position.set(-1.75, 0.335, -0.55); const foot = new THREE.Mesh(this.track(new THREE.CylinderGeometry(0.2, 0.22, 0.03, 10)), stem.material); foot.position.set(-1.75, 0.015, -0.55); this.group.add(tableTop, stem, foot); // A mug on it. const mug = new THREE.Mesh(this.track(new THREE.CylinderGeometry(0.04, 0.035, 0.09, 8)), this.track(new THREE.MeshLambertMaterial({ color: '#e8e2d6' }))); mug.position.set(-1.65, 0.74, -0.6); this.group.add(mug); // The bench, against the doors. plainBox(parts, 0.95, 2.95, 0.41, 0.46, 0.38, 0.82, wood); plainBox(parts, 0.95, 2.95, 0.55, 0.9, 0.86, 0.9, wood); for (const x of [1.02, 2.88]) { plainBox(parts, x - 0.03, x + 0.03, 0, 0.41, 0.42, 0.48, steel); plainBox(parts, x - 0.03, x + 0.03, 0, 0.9, 0.82, 0.88, steel); } // Planters: concrete troughs in the two front corners. const trough = new THREE.Color('#8e8c88'); const planters = [[-BALCONY.halfX + 0.3, BALCONY.front + 0.32], [BALCONY.halfX - 0.3, BALCONY.front + 0.32]]; for (const [x, z] of planters) plainBox(parts, x - 0.25, x + 0.25, 0, 0.5, z - 0.25, z + 0.25, trough); this.group.add(new THREE.Mesh(this.track(parts.geometry()), this.furnitureMat)); // Low-poly shrubs in them. const leaves = this.track(new THREE.MeshLambertMaterial({ color: '#4f6a45', flatShading: true })); const bush = this.track(new THREE.IcosahedronGeometry(0.28, 0)); for (const [x, z] of planters) { for (let i = 0; i < 3; i++) { const b = new THREE.Mesh(bush, leaves); b.position.set(x + (r() - 0.5) * 0.2, 0.68 + i * 0.16, z + (r() - 0.5) * 0.2); b.scale.setScalar(1 - i * 0.22); b.rotation.set(r() * 3, r() * 3, r() * 3); this.group.add(b); } } }, // A thin layer far below that hides the street, and the overcast just // above, both drifting slowly. buildClouds() { const r = this.rand; this.clouds = []; for (const [y, base, opacity, repeat, drift] of [ [CLOUDS.below, 0.5, 0.9, 5, [0.0009, 0.0004]], [CLOUDS.above, 0.45, 0.95, 4, [-0.0006, 0.0005]], ]) { const map = this.track(cloudTexture(r, base)); map.repeat.set(repeat, repeat); const mesh = new THREE.Mesh( this.track(new THREE.PlaneGeometry(CLOUDS.size, CLOUDS.size)), this.track(new THREE.MeshBasicMaterial({ map, color: '#dde1e4', transparent: true, opacity, depthWrite: false, side: THREE.DoubleSide })), ); mesh.rotation.x = -Math.PI / 2; mesh.position.y = y; mesh.renderOrder = 2; this.group.add(mesh); this.clouds.push({ map, drift }); } }, tick(time, delta) { const dt = Math.min(delta || 0, 100) / 1000; for (const { map, drift } of this.clouds) { map.offset.x = (map.offset.x + drift[0] * dt) % 1; map.offset.y = (map.offset.y + drift[1] * dt) % 1; } // A short bright flash, then dark. const phase = (time / 1000 / BLINK) % 1; this.lightMaterial.color.setRGB(phase < 0.35 ? 1 : 0.06, phase < 0.35 ? 0.1 : 0.01, phase < 0.35 ? 0.06 : 0.005); }, remove() { for (const d of this.disposables) d.dispose(); this.el.removeObject3D('mesh'); }, // Keeps a head position on the balcony, back from the glass. confine(p) { const B = BALCONY; p.x = Math.min(B.halfX - MARGIN, Math.max(-B.halfX + MARGIN, p.x)); p.z = Math.min(B.back - MARGIN, Math.max(B.front + MARGIN, p.z)); return p; }, }); })();