/* global AFRAME, THREE */ // The world: a clearing in a pine forest at night, with a crackling campfire, // log seats, tents, a little pond and fireflies. Models are Kenney's Nature Kit // (CC0). Everything is laid out from a fixed seed so everyone sees the same // place, and repeated models are drawn as instanced meshes to stay light on // standalone headsets. (function () { const ENV_DIR = 'assets/env/'; const SEED = 20261030; const PINES = [ 'tree_pineTallA', 'tree_pineTallB', 'tree_pineTallC', 'tree_pineTallD', 'tree_pineRoundA', 'tree_pineRoundC', 'tree_pineRoundE', 'tree_pineDefaultA', 'tree_pineDefaultB', ]; const UNDERGROWTH = [ 'plant_bush', 'plant_bushLarge', 'plant_bushDetailed', 'grass_large', 'grass', 'flower_redA', 'flower_yellowA', 'flower_purpleA', 'mushroom_redGroup', 'mushroom_tanGroup', ]; const ROCKS = ['rock_largeA', 'rock_largeC', 'rock_tallB', 'rock_tallE']; const POND = { x: 9.5, z: -8.5, r: 3.2 }; // Seat heights: the models' tops (less their -0.05 node offset) at the scales placed below. const LOG_TOP = (0.173 - 0.05) * 3.2; const STUMP_TOP = (0.206 - 0.05) * 2.6; 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; }; } function polar(radius, degrees) { const a = THREE.MathUtils.degToRad(degrees); return { x: Math.sin(a) * radius, z: Math.cos(a) * radius, angle: a }; } function glowTexture(inner, outer) { const size = 64; const canvas = document.createElement('canvas'); canvas.width = canvas.height = size; const ctx = canvas.getContext('2d'); const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); g.addColorStop(0, inner); g.addColorStop(0.35, outer); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, size, size); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; return texture; } // A pumpkin of radius 1: eight lobes, squashed, dimpled where the stem // grows. Its face (texture u = 0.25) points along +z. function pumpkinGeometry() { const geometry = new THREE.SphereGeometry(1, 40, 24); const p = geometry.attributes.position; for (let i = 0; i < p.count; i++) { const x = p.getX(i); const y = p.getY(i); const z = p.getZ(i); const around = Math.hypot(x, z); const groove = Math.pow(1 - Math.abs(Math.sin(Math.atan2(z, x) * 4)), 3); const r = 1 - 0.09 * groove * around; p.setXYZ(i, x * r, y * 0.7 - Math.sign(y) * 0.14 * (1 - around) ** 2, z * r); } geometry.computeVertexNormals(); return geometry; } // Skin and carved face, painted onto the sphere's texture: the holes are // dark in the colour map and bright in the glow map. Two faces: a classic // triangle-eyed grin, and a meaner one. function pumpkinTextures(face) { const W = 256; const H = 128; const paint = (draw) => { const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; draw(canvas.getContext('2d')); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; return texture; }; // Face centre at (64, 64); offsets are stretched vertically to undo the squash. const pt = (x, y) => [64 + x, 64 + y * 1.35]; const smile = (w, top, bottom, teeth) => { const upper = []; const lower = []; for (let i = 0; i <= teeth * 2; i++) { const x = -w + (i * w) / teeth; const bow = 1 - (x / w) ** 2; upper.push(pt(x, top + 6 * bow + (i % 2 && i < teeth * 2 ? 4 : 0))); lower.unshift(pt(x, bottom + 12 * bow)); } return upper.concat(lower); }; const holes = face === 0 ? [ [pt(-25, -6), pt(-9, -6), pt(-17, -22)], [pt(9, -6), pt(25, -6), pt(17, -22)], [pt(-5, 3), pt(5, 3), pt(0, -5)], smile(30, 8, 12, 3), ] : [ [pt(-27, -18), pt(-8, -9), pt(-10, -3), pt(-25, -9)], [pt(27, -18), pt(8, -9), pt(10, -3), pt(25, -9)], [pt(-32, 0), pt(-18, 6), pt(-12, 4), pt(-12, 12), pt(-4, 8), pt(4, 8), pt(12, 12), pt(12, 4), pt(18, 6), pt(32, 0), pt(22, 17), pt(0, 22), pt(-22, 17)], ]; const fill = (ctx, style, blur) => { ctx.fillStyle = style; ctx.filter = blur; for (const hole of holes) { ctx.beginPath(); hole.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); ctx.closePath(); ctx.fill(); } }; return { map: paint((ctx) => { ctx.fillStyle = '#d9661c'; ctx.fillRect(0, 0, W, H); fill(ctx, '#2a1204', 'none'); }), glow: paint((ctx) => { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H); fill(ctx, '#ffb347', 'blur(1px)'); }), }; } function lambert(material) { const m = new THREE.MeshLambertMaterial({ color: material.color ? material.color.clone() : new THREE.Color(0xffffff), map: material.map || null, vertexColors: material.vertexColors, side: material.side, transparent: material.transparent, opacity: material.opacity, }); material.dispose(); return m; } AFRAME.registerComponent('campsite', { init() { this.rand = mulberry32(SEED); this.placements = new Map(); // model name -> [Matrix4] this.blocked = []; // circles kept clear of scattered stuff this.seats = []; // where people can sit; indexes are synced this.screen = { x: 0, y: 1.9, z: 6.2, yaw: Math.PI, width: 3.2 }; // at the clearing's edge, facing the fire this.animated = []; this.buildGround(); this.buildFire(); this.buildFireflies(); this.buildPumpkins(); this.layout(); this.loadModels(); }, tick(time, delta) { const t = time / 1000; for (const fn of this.animated) fn(t, Math.min(delta, 100) / 1000); }, // --- placement helpers --------------------------------------------------- place(model, x, z, { scale = 3, yaw = 0, y = 0 } = {}) { const m = new THREE.Matrix4().compose( new THREE.Vector3(x, y, z), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yaw), new THREE.Vector3(scale, scale, scale), ); if (!this.placements.has(model)) this.placements.set(model, []); this.placements.get(model).push(m); }, block(x, z, r) { this.blocked.push({ x, z, r }); }, // A place to sit, facing the fire. `yaw` turns -Z (where people face) toward it. addSeat(x, z, y) { this.seats.push({ x, y, z, yaw: Math.atan2(x, z) }); }, isClear(x, z, r) { return this.blocked.every((b) => Math.hypot(x - b.x, z - b.z) > b.r + r); }, scatter(models, count, minR, maxR, { minScale, maxScale, keepOut = 0 }) { let placed = 0; for (let tries = 0; placed < count && tries < count * 20; tries++) { const r = minR + Math.sqrt(this.rand()) * (maxR - minR); const p = polar(r, this.rand() * 360); if (!this.isClear(p.x, p.z, keepOut)) continue; const model = models[Math.floor(this.rand() * models.length)]; const scale = minScale + this.rand() * (maxScale - minScale); this.place(model, p.x, p.z, { scale, yaw: this.rand() * Math.PI * 2 }); if (keepOut) this.block(p.x, p.z, keepOut * 0.8); placed++; } }, // --- the scene ----------------------------------------------------------- buildGround() { const group = this.el.object3D; const forestFloor = new THREE.Mesh( new THREE.CircleGeometry(120, 48).rotateX(-Math.PI / 2), new THREE.MeshLambertMaterial({ color: '#203320' }), ); group.add(forestFloor); const clearing = new THREE.Mesh( new THREE.CircleGeometry(6.5, 40).rotateX(-Math.PI / 2), new THREE.MeshLambertMaterial({ color: '#4b3d2c', polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }), ); clearing.position.y = 0.005; group.add(clearing); const pond = new THREE.Mesh( new THREE.CircleGeometry(POND.r, 32).rotateX(-Math.PI / 2), new THREE.MeshPhongMaterial({ color: '#0b1626', specular: '#7f93c9', shininess: 90, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }), ); pond.position.set(POND.x, 0.01, POND.z); group.add(pond); }, buildFire() { const fire = new THREE.Group(); fire.position.set(0, 0.12, 0); this.el.object3D.add(fire); const flameColors = ['#ff5a14', '#ff8c1a', '#ffb23b', '#ffe08a']; const flames = flameColors.map((color, i) => { const height = 1.1 - i * 0.18; const mesh = new THREE.Mesh( new THREE.ConeGeometry(0.32 - i * 0.06, height, 8, 1, true).translate(0, height / 2, 0), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.55 + i * 0.1, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, fog: false }), ); mesh.position.set((i % 2 ? 0.05 : -0.05), 0, (i % 3 ? -0.04 : 0.04)); fire.add(mesh); return { mesh, phase: i * 1.7, base: 1 }; }); const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTexture('rgba(255,190,110,0.9)', 'rgba(255,110,30,0.35)'), blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, fog: false, })); glow.position.y = 0.45; glow.scale.set(3.2, 3.2, 1); fire.add(glow); // Embers drifting up. const EMBERS = 60; const positions = new Float32Array(EMBERS * 3); const speeds = []; for (let i = 0; i < EMBERS; i++) { positions[i * 3 + 1] = Math.random() * 3; speeds.push(0.5 + Math.random() * 0.9); } const emberGeo = new THREE.BufferGeometry(); emberGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const embers = new THREE.Points(emberGeo, new THREE.PointsMaterial({ color: '#ffae5c', size: 0.045, map: glowTexture('rgba(255,255,255,1)', 'rgba(255,170,80,0.6)'), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false, })); fire.add(embers); // Flickering firelight, as an A-Frame light so the default lights stay off. // It fades out by the edge of the clearing, so the forest beyond is dark. const light = document.createElement('a-entity'); light.setAttribute('light', { type: 'point', color: '#ff9a4d', intensity: 15, distance: 12, decay: 2 }); light.setAttribute('position', '0 1.1 0'); this.el.appendChild(light); this.animated.push((t, dt) => { for (const f of flames) { const n = Math.sin(t * 9 + f.phase) * 0.12 + Math.sin(t * 15.3 + f.phase * 2) * 0.08; f.mesh.scale.set(1 + n * 0.4, 1 + n, 1 + n * 0.4); f.mesh.rotation.y = t * 0.7 + f.phase; } const flicker = 1 + Math.sin(t * 11) * 0.06 + Math.sin(t * 23.7) * 0.05 + (Math.random() - 0.5) * 0.05; glow.material.opacity = 0.75 * flicker; const lightObj = light.getObject3D('light'); if (lightObj) lightObj.intensity = 15 * flicker; const pos = emberGeo.attributes.position; for (let i = 0; i < EMBERS; i++) { let y = pos.getY(i) + speeds[i] * dt; let x = pos.getX(i) + Math.sin(t * 2 + i) * 0.12 * dt; let z = pos.getZ(i) + Math.cos(t * 1.7 + i * 0.5) * 0.12 * dt; if (y > 3) { y = 0.2; x = (Math.random() - 0.5) * 0.4; z = (Math.random() - 0.5) * 0.4; } pos.setXYZ(i, x, y, z); } pos.needsUpdate = true; }); }, buildFireflies() { const COUNT = 36; const anchors = []; const positions = new Float32Array(COUNT * 3); const colors = new Float32Array(COUNT * 3); for (let i = 0; i < COUNT; i++) { const p = polar(6 + this.rand() * 10, this.rand() * 360); anchors.push({ x: p.x, z: p.z, y: 0.5 + this.rand() * 1.8, phase: this.rand() * 10, speed: 0.3 + this.rand() * 0.4 }); } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); const flies = new THREE.Points(geo, new THREE.PointsMaterial({ size: 0.07, vertexColors: true, map: glowTexture('rgba(255,255,255,1)', 'rgba(220,255,140,0.5)'), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false, })); this.el.object3D.add(flies); const tint = new THREE.Color('#d8ff7a'); this.animated.push((t) => { for (let i = 0; i < COUNT; i++) { const a = anchors[i]; const s = t * a.speed + a.phase; positions[i * 3] = a.x + Math.sin(s) * 0.8; positions[i * 3 + 1] = a.y + Math.sin(s * 1.7) * 0.25; positions[i * 3 + 2] = a.z + Math.cos(s * 0.8) * 0.8; const on = Math.max(0, Math.sin(s * 2.3)) ** 3; colors[i * 3] = tint.r * on; colors[i * 3 + 1] = tint.g * on; colors[i * 3 + 2] = tint.b * on; } geo.attributes.position.needsUpdate = true; geo.attributes.color.needsUpdate = true; }); }, // Jack-o'-lanterns for Halloween, at the edge of the firelight and facing // the fire, with a couple of uncarved ones. Each has a flickering candle. buildPumpkins() { // Their own random numbers, so the forest stays where it was. const rand = mulberry32(SEED + 31); const geometry = pumpkinGeometry(); const stemGeometry = new THREE.CylinderGeometry(0.07, 0.12, 0.4, 6).translate(0, 0.2, 0); const stemMaterial = new THREE.MeshLambertMaterial({ color: '#5a4a24' }); const faces = [0, 1].map((face) => { const { map, glow } = pumpkinTextures(face); return new THREE.MeshLambertMaterial({ map, emissive: '#ffffff', emissiveMap: glow, emissiveIntensity: 1.4 }); }); const plain = new THREE.MeshLambertMaterial({ color: '#d9661c' }); const candle = glowTexture('rgba(255,180,80,0.5)', 'rgba(255,110,20,0.15)'); // [angle around the fire (degrees), distance, size (radius, m), face or null] this.pumpkins = [ [38, 4.5, 0.22, 0], [82, 4.8, 0.14, null], [140, 4.4, 0.26, 1], [196, 4.6, 0.2, 0], [298, 4.5, 0.24, 1], [314, 4.9, 0.15, null], ]; const glows = []; for (const [deg, dist, size, face] of this.pumpkins) { const p = polar(dist, deg); const yaw = p.angle + Math.PI + (rand() - 0.5) * 0.5; const pumpkin = new THREE.Group(); pumpkin.position.set(p.x, 0.56 * size, p.z); pumpkin.rotation.y = yaw; pumpkin.scale.setScalar(size); pumpkin.add(new THREE.Mesh(geometry, face === null ? plain : faces[face])); const stem = new THREE.Mesh(stemGeometry, stemMaterial); stem.position.y = 0.5; stem.rotation.set((rand() - 0.5) * 0.5, 0, (rand() - 0.5) * 0.5); pumpkin.add(stem); this.el.object3D.add(pumpkin); if (face === null) continue; const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: candle, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, fog: false, })); sprite.position.set(p.x + Math.sin(yaw) * size * 1.1, 0.6 * size, p.z + Math.cos(yaw) * size * 1.1); sprite.scale.setScalar(size * 5); this.el.object3D.add(sprite); glows.push({ sprite, phase: rand() * 10 }); } this.animated.push((t) => { faces.forEach((m, i) => { m.emissiveIntensity = 1.4 * (0.82 + 0.1 * Math.sin(t * 9.1 + i * 2) + 0.08 * Math.sin(t * 23.3 + i)); }); for (const g of glows) { g.sprite.material.opacity = 0.75 + 0.15 * Math.sin(t * 8.3 + g.phase) + 0.1 * Math.sin(t * 19.7 + g.phase); } }); }, layout() { // Fire ring. this.place('campfire_stones', 0, 0, { scale: 3.2 }); this.place('campfire_logs', 0, 0, { scale: 3.2, yaw: 0.4, y: 0.02 }); this.block(0, 0, 3.4); // Log seats around the fire, with a gap to walk in. Each log seats two. for (const deg of [20, 100, 180, 260]) { const p = polar(2.55, deg); this.place('log', p.x, p.z, { scale: 3.2, yaw: p.angle + Math.PI / 2 }); for (const along of [-0.5, 0.5]) { this.addSeat(p.x + Math.cos(p.angle) * along, p.z - Math.sin(p.angle) * along, LOG_TOP); } } for (const deg of [60, 220]) { const p = polar(3.1, deg); this.place('stump_round', p.x, p.z, { scale: 2.6, yaw: p.angle }); this.addSeat(p.x, p.z, STUMP_TOP); } // Tents, facing the fire. const bigTent = polar(7.6, 205); this.place('tent_detailedOpen', bigTent.x, bigTent.z, { scale: 4.2, yaw: bigTent.angle + Math.PI }); this.block(bigTent.x, bigTent.z, 2.4); const smallTent = polar(7.8, 150); this.place('tent_smallClosed', smallTent.x, smallTent.z, { scale: 4.2, yaw: smallTent.angle + Math.PI }); this.block(smallTent.x, smallTent.z, 1.8); const woodpile = polar(5.6, 235); this.place('log_stack', woodpile.x, woodpile.z, { scale: 3.2, yaw: woodpile.angle }); this.place('stump_old', polar(6.2, 120).x, polar(6.2, 120).z, { scale: 3 }); // Pond with lily pads and a canoe pulled up on the bank. this.block(POND.x, POND.z, POND.r + 0.6); this.place('canoe', POND.x - 3.6, POND.z + 2.6, { scale: 3.4, yaw: 0.9, y: 0.02 }); this.place('lily_large', POND.x + 0.8, POND.z - 0.6, { scale: 3, y: 0.0 }); this.place('lily_small', POND.x - 1.1, POND.z + 0.9, { scale: 3, yaw: 1.2, y: 0.0 }); this.place('lily_large', POND.x - 0.4, POND.z - 1.8, { scale: 2.6, yaw: 2.4, y: 0.0 }); for (let i = 0; i < 10; i++) { const a = (i / 10) * Math.PI * 2 + 0.3; const r = POND.r + 0.35; this.place(i % 2 ? 'grass_large' : 'plant_bush', POND.x + Math.sin(a) * r, POND.z + Math.cos(a) * r, { scale: 3, yaw: a }); } // Forest ring, undergrowth and boulders. this.scatter(ROCKS, 12, 7.5, 18, { minScale: 3, maxScale: 5, keepOut: 1.2 }); this.scatter(PINES, 120, 12.5, 42, { minScale: 3.6, maxScale: 6.5, keepOut: 1.0 }); this.scatter(PINES, 14, 8.5, 12.5, { minScale: 2.8, maxScale: 4, keepOut: 1.4 }); // Keep the undergrowth off the pumpkins (only it reaches in this close). for (const [deg, dist, size] of this.pumpkins) { const p = polar(dist, deg); this.block(p.x, p.z, size + 0.3); } this.scatter(UNDERGROWTH, 70, 4.2, 16, { minScale: 2.4, maxScale: 3.6, keepOut: 0.3 }); }, loadModels() { const loader = new THREE.GLTFLoader(); const up = new THREE.Matrix4(); for (const [name, matrices] of this.placements) { loader.load(`${ENV_DIR}${name}.glb`, (gltf) => { gltf.scene.updateMatrixWorld(true); gltf.scene.traverse((obj) => { if (!obj.isMesh) return; const material = Array.isArray(obj.material) ? obj.material.map(lambert) : lambert(obj.material); const mesh = new THREE.InstancedMesh(obj.geometry, material, matrices.length); matrices.forEach((m, i) => mesh.setMatrixAt(i, up.multiplyMatrices(m, obj.matrixWorld))); mesh.instanceMatrix.needsUpdate = true; mesh.computeBoundingSphere(); this.el.object3D.add(mesh); }); }, undefined, (err) => console.warn(`Couldn't load ${name}`, err)); } }, }); })();