summaryrefslogtreecommitdiff
path: root/tools/portraits
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 /tools/portraits
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 'tools/portraits')
-rw-r--r--tools/portraits/render.html85
-rw-r--r--tools/portraits/server.ts27
2 files changed, 112 insertions, 0 deletions
diff --git a/tools/portraits/render.html b/tools/portraits/render.html
new file mode 100644
index 0000000..8e0a26e
--- /dev/null
+++ b/tools/portraits/render.html
@@ -0,0 +1,85 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>render</title>
+<body style="margin:0">
+<script src="/vendor/aframe-1.7.1.min.js"></script>
+<script>
+// Renders a transparent head-and-torso portrait of each avatar, arms relaxed,
+// three-quarter view, and POSTs each PNG back to the helper server.
+const SLUGS = (new URLSearchParams(location.search).get('slugs') ||
+ 'oldmoustache,bigbro,lilbro,teddy,froggy,astronaut,polybot,wizzir').split(',');
+const SIZE = 384;
+
+const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
+renderer.setPixelRatio(1);
+renderer.setSize(SIZE, SIZE);
+renderer.outputColorSpace = THREE.SRGBColorSpace;
+renderer.setClearColor(0x000000, 0);
+document.body.appendChild(renderer.domElement);
+
+const loader = new THREE.GLTFLoader();
+const load = (url) => new Promise((res, rej) => loader.load(url, res, undefined, rej));
+
+function relaxArms(bone) {
+ const shoulder = new THREE.Vector3(), wrist = new THREE.Vector3(), q = new THREE.Quaternion(), pq = new THREE.Quaternion();
+ for (const [side, sign] of [['left', -1], ['right', 1]]) {
+ const upper = bone(`${side}UpperArm`), hand = bone(`${side}Hand`);
+ if (!upper || !hand) continue;
+ upper.getWorldPosition(shoulder);
+ hand.getWorldPosition(wrist);
+ const from = wrist.sub(shoulder).normalize();
+ const to = new THREE.Vector3(sign * 0.28, -1, -0.12).normalize();
+ q.setFromUnitVectors(from, to);
+ const world = upper.getWorldQuaternion(new THREE.Quaternion()).premultiply(q);
+ upper.parent.getWorldQuaternion(pq).invert();
+ upper.quaternion.copy(pq).multiply(world);
+ upper.updateMatrixWorld(true);
+ }
+}
+
+async function renderOne(slug) {
+ const gltf = await load(`/assets/avatars/${slug}.vrm`);
+ const vrm = gltf.parser.json.extensions.VRM;
+ const nodes = new Map();
+ gltf.scene.traverse((o) => {
+ const a = gltf.parser.associations.get(o);
+ if (a && a.nodes !== undefined) nodes.set(a.nodes, o);
+ if (o.isMesh) o.frustumCulled = false;
+ });
+ const bone = (name) => {
+ const hb = vrm.humanoid.humanBones.find((b) => b.bone === name);
+ return hb ? nodes.get(hb.node) : null;
+ };
+
+ const scene = new THREE.Scene();
+ scene.add(gltf.scene);
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 2));
+ gltf.scene.updateMatrixWorld(true);
+ relaxArms(bone);
+
+ const top = new THREE.Box3().setFromObject(gltf.scene).max.y;
+ const hips = bone('hips').getWorldPosition(new THREE.Vector3()).y;
+ const bottom = hips - (top - hips) * 0.05;
+ const centerY = (top + bottom) / 2;
+ const height = (top - bottom) * 1.2;
+
+ const camera = new THREE.PerspectiveCamera(28, 1, 0.01, 100);
+ const dist = (height / 2) / Math.tan(THREE.MathUtils.degToRad(14));
+ const yaw = THREE.MathUtils.degToRad(22);
+ camera.position.set(Math.sin(yaw) * dist, centerY + height * 0.04, -Math.cos(yaw) * dist);
+ camera.lookAt(0, centerY, 0);
+
+ renderer.render(scene, camera);
+ const blob = await new Promise((res) => renderer.domElement.toBlob(res, 'image/png'));
+ await fetch(`/save/${slug}`, { method: 'POST', body: blob });
+}
+
+(async () => {
+ const results = {};
+ for (const slug of SLUGS) {
+ try { await renderOne(slug); results[slug] = 'ok'; } catch (e) { results[slug] = String(e); }
+ }
+ window.results = results;
+ document.title = 'done';
+})();
+</script>
diff --git a/tools/portraits/server.ts b/tools/portraits/server.ts
new file mode 100644
index 0000000..8af80f5
--- /dev/null
+++ b/tools/portraits/server.ts
@@ -0,0 +1,27 @@
+// Renders avatar portraits for the lobby. Serves render.html plus the project's
+// public files, and saves the PNGs the page POSTs back into ./out.
+//
+// deno run --allow-net=127.0.0.1:8097 --allow-read --allow-write=tools/portraits tools/portraits/server.ts
+// then open http://localhost:8097/ (or ?slugs=name1,name2) and wait for the title "done".
+const HERE = new URL('.', import.meta.url).pathname;
+const PUBLIC = new URL('../../public', import.meta.url).pathname;
+await Deno.mkdir(`${HERE}out`, { recursive: true });
+
+const TYPES: Record<string, string> = { html: 'text/html', js: 'text/javascript', vrm: 'application/octet-stream' };
+
+Deno.serve({ port: 8097, hostname: '127.0.0.1' }, async (req) => {
+ const url = new URL(req.url);
+ if (url.pathname.includes('..')) return new Response('no', { status: 400 });
+ if (req.method === 'POST' && url.pathname.startsWith('/save/')) {
+ const slug = url.pathname.slice(6).replace(/[^a-z0-9-]/g, '');
+ await Deno.writeFile(`${HERE}out/${slug}.png`, new Uint8Array(await req.arrayBuffer()));
+ return new Response('ok');
+ }
+ const file = url.pathname === '/' ? `${HERE}render.html` : PUBLIC + url.pathname;
+ try {
+ const ext = file.split('.').pop() || '';
+ return new Response(await Deno.readFile(file), { headers: { 'content-type': TYPES[ext] || 'application/octet-stream' } });
+ } catch {
+ return new Response('not found', { status: 404 });
+ }
+});