summaryrefslogtreecommitdiff
path: root/public/js/components/campsite.js
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-01 05:43:24 -0800
committerskylar <me@casper>2026-11-01 05:43:24 -0800
commite69ad01e700e80fa959dfee08cd556ca0d777ffd (patch)
tree645fc9db2142e87a5ef302733a77ba2fec848d88 /public/js/components/campsite.js
Yesod: self-hosted WebXR rooms
Start a room in a world and hang out in VR from the headset's browser: A-Frame + Networked-A-Frame over EasyRTC, with a coturn relay that can hide everyone's IP address. Ships with the Campfire, Temple, Cabin, Balcony and Test room worlds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'public/js/components/campsite.js')
-rw-r--r--public/js/components/campsite.js479
1 files changed, 479 insertions, 0 deletions
diff --git a/public/js/components/campsite.js b/public/js/components/campsite.js
new file mode 100644
index 0000000..0a227a1
--- /dev/null
+++ b/public/js/components/campsite.js
@@ -0,0 +1,479 @@
+/* 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));
+ }
+ },
+ });
+})();