From 5ffb7cdfdc380cc7d6afffb020587e9d423108b7 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 18 Jul 2026 19:27:46 +0700 Subject: [PATCH] feat: draw Reigen Arataka with Three.js and multi-shot camera Procedural head, face and slicked-back hair rendered with Three.js primitives, seven switchable camera shots, orbit controls and auto-rotate. Add GitHub Pages deploy workflow. --- .github/workflows/deploy-pages.yml | 39 ++++ .nojekyll | 0 README.md | 47 ++++- css/style.css | 134 +++++++++++++ index.html | 37 ++++ js/camera-shots.js | 61 ++++++ js/main.js | 143 ++++++++++++++ js/reigen-character.js | 294 +++++++++++++++++++++++++++++ 8 files changed, 753 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/deploy-pages.yml create mode 100644 .nojekyll create mode 100644 css/style.css create mode 100644 index.html create mode 100644 js/camera-shots.js create mode 100644 js/main.js create mode 100644 js/reigen-character.js diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..fd59dd3 --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,39 @@ +name: Deploy to GitHub Pages + +# Publishes the static site (repo root) to GitHub Pages on every push to main. +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment; newer pushes cancel in-progress runs. +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload site artifact + uses: actions/upload-pages-artifact@v3 + with: + path: . + + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 1d73aa8..e9a4c06 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,45 @@ -# reigen -reigen +# Reigen Arataka — Three.js + +A tiny web app that draws **Reigen Arataka** from *Mob Psycho 100* — his face +and slicked-back hair — as a procedural 3D model built with **plain JavaScript +and Three.js only**. No build step, no framework. + +**Live demo:** https://tiennm99.github.io/reigen/ + +## Features + +- Reigen's head, face (half-lidded "unimpressed" eyes, smug mouth, brows, nose, + ears), side-parted swept-back hair, and a suit + red tie bust. +- **7 camera shots** you can switch between with a smooth tween: + front, 3/4 view, side profile, face close-up, "the stare", hair/top, low hero. +- Orbit with the mouse (drag to rotate, scroll to zoom) and an auto-rotate toggle. +- Warm key + cool rim lighting for an anime-portrait feel. + +## Run locally + +Because it uses ES modules and an import map, open it through a local server +(not `file://`): + +```bash +python3 -m http.server 8099 +# then visit http://localhost:8099 +``` + +## Project structure + +``` +index.html # markup, import map, UI chrome +css/style.css # layout and controls styling +js/main.js # renderer, lights, controls, shot switching, loop +js/reigen-character.js # procedural Reigen model (head, face, hair, bust) +js/camera-shots.js # named camera positions/targets +.github/workflows/ # GitHub Pages deployment +``` + +Three.js is loaded from a CDN via the import map in `index.html`. + +## Deployment + +Pushing to `main` triggers the GitHub Actions workflow +(`.github/workflows/deploy-pages.yml`), which publishes the repo root to GitHub +Pages. Enable Pages once under **Settings → Pages → Source: GitHub Actions**. diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..77960af --- /dev/null +++ b/css/style.css @@ -0,0 +1,134 @@ +/* Reigen Arataka Three.js showcase — layout & UI chrome only. + All 3D rendering happens on #scene. */ + +:root { + --ink: #1c1a17; + --paper: #f4efe6; + --accent: #c8963e; /* Reigen's warm suit/hair tone */ + --panel: rgba(28, 26, 23, 0.72); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + height: 100%; + overflow: hidden; + background: #0b0b0d; + color: var(--paper); + font-family: "Trebuchet MS", "Segoe UI", system-ui, sans-serif; +} + +#app { + position: relative; + width: 100vw; + height: 100vh; +} + +#scene { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; +} + +#titlebar { + position: absolute; + top: 18px; + left: 22px; + pointer-events: none; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6); +} + +#titlebar h1 { + font-size: clamp(1.1rem, 2.4vw, 1.8rem); + letter-spacing: 0.04em; + color: var(--paper); +} + +#titlebar p { + font-size: clamp(0.7rem, 1.4vw, 0.9rem); + color: var(--accent); + margin-top: 4px; +} + +#shot-bar { + position: absolute; + right: 22px; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 8px; + z-index: 5; +} + +.shot-btn { + min-width: 116px; + padding: 9px 14px; + border: 1px solid rgba(200, 150, 62, 0.5); + border-radius: 999px; + background: var(--panel); + color: var(--paper); + font: inherit; + font-size: 0.82rem; + letter-spacing: 0.02em; + cursor: pointer; + backdrop-filter: blur(6px); + transition: background 0.2s, transform 0.15s, border-color 0.2s; + text-align: left; +} + +.shot-btn:hover { + background: rgba(200, 150, 62, 0.25); + transform: translateX(-3px); +} + +.shot-btn.active { + background: var(--accent); + color: #201a10; + border-color: var(--accent); + font-weight: 700; +} + +#hint { + position: absolute; + bottom: 16px; + left: 22px; + display: flex; + align-items: center; + gap: 18px; + font-size: 0.78rem; + color: rgba(244, 239, 230, 0.72); + z-index: 5; +} + +#hint label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +@media (max-width: 620px) { + #shot-bar { + top: auto; + bottom: 58px; + right: 12px; + left: 12px; + transform: none; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + } + .shot-btn { + min-width: 0; + flex: 1 1 30%; + text-align: center; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..05f7b17 --- /dev/null +++ b/index.html @@ -0,0 +1,37 @@ + + + + + + Reigen Arataka — Three.js + + + + + +
+ + +
+

霊幻 新隆 · Reigen Arataka

+

The Greatest Psychic of the 21st Century — built with Three.js

+
+ + + +
+ Drag to orbit · scroll to zoom + +
+
+ + + + diff --git a/js/camera-shots.js b/js/camera-shots.js new file mode 100644 index 0000000..d4b794e --- /dev/null +++ b/js/camera-shots.js @@ -0,0 +1,61 @@ +// Named camera shots for framing Reigen. Each shot defines where the camera +// sits and where it looks. main.js tweens between them when a button is pressed. +import * as THREE from "three"; + +/** + * @typedef {Object} Shot + * @property {string} id + * @property {string} label + * @property {THREE.Vector3} position - camera position + * @property {THREE.Vector3} target - orbit/look-at target + */ + +/** @type {Shot[]} */ +export const SHOTS = [ + { + id: "front", + label: "① Front", + position: new THREE.Vector3(0, 0.2, 6.2), + target: new THREE.Vector3(0, 0, 0), + }, + { + id: "three-quarter", + label: "② 3/4 View", + position: new THREE.Vector3(3.8, 0.6, 4.8), + target: new THREE.Vector3(0, 0, 0), + }, + { + id: "profile", + label: "③ Side Profile", + position: new THREE.Vector3(6.0, 0.1, 0.4), + target: new THREE.Vector3(0, -0.1, 0), + }, + { + id: "face-closeup", + label: "④ Face Close-up", + position: new THREE.Vector3(0, 0.25, 3.2), + target: new THREE.Vector3(0, 0.15, 0.4), + }, + { + id: "eyes", + label: "⑤ The Stare", + position: new THREE.Vector3(1.1, 0.3, 2.6), + target: new THREE.Vector3(0, 0.2, 0.6), + }, + { + id: "hair-top", + label: "⑥ Hair / Top", + position: new THREE.Vector3(0.2, 4.6, 3.2), + target: new THREE.Vector3(0, 0.7, 0), + }, + { + id: "low-hero", + label: "⑦ Low Hero", + position: new THREE.Vector3(-2.6, -2.2, 5.0), + target: new THREE.Vector3(0, 0.1, 0), + }, +]; + +export function getShot(id) { + return SHOTS.find((s) => s.id === id) ?? SHOTS[0]; +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..795091d --- /dev/null +++ b/js/main.js @@ -0,0 +1,143 @@ +// Scene bootstrap: renderer, lights, Reigen, OrbitControls and shot switching. +import * as THREE from "three"; +import { OrbitControls } from "three/addons/controls/OrbitControls.js"; +import { createReigen } from "./reigen-character.js"; +import { SHOTS, getShot } from "./camera-shots.js"; + +const canvas = document.getElementById("scene"); + +// --- Renderer --------------------------------------------------------------- +const renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, +}); +renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); +renderer.setSize(window.innerWidth, window.innerHeight); +renderer.shadowMap.enabled = true; +renderer.shadowMap.type = THREE.PCFSoftShadowMap; + +// --- Scene ------------------------------------------------------------------ +const scene = new THREE.Scene(); +scene.background = new THREE.Color(0x14141a); +scene.fog = new THREE.Fog(0x14141a, 10, 22); + +// --- Camera ----------------------------------------------------------------- +const camera = new THREE.PerspectiveCamera( + 42, + window.innerWidth / window.innerHeight, + 0.1, + 100 +); +camera.position.copy(SHOTS[0].position); + +// --- Lighting: warm key + cool rim for that anime portrait feel ------------- +const ambient = new THREE.AmbientLight(0xffffff, 0.55); +scene.add(ambient); + +const key = new THREE.DirectionalLight(0xfff2d6, 1.5); +key.position.set(4, 6, 6); +key.castShadow = true; +key.shadow.mapSize.set(1024, 1024); +scene.add(key); + +const rim = new THREE.DirectionalLight(0x6fa8ff, 0.8); +rim.position.set(-5, 2, -4); +scene.add(rim); + +const fill = new THREE.PointLight(0xffd9a0, 0.6, 30); +fill.position.set(-3, -2, 4); +scene.add(fill); + +// --- Floor: subtle disc so shadows have somewhere to land ------------------- +const floor = new THREE.Mesh( + new THREE.CircleGeometry(9, 64), + new THREE.MeshStandardMaterial({ color: 0x1d1d26, roughness: 1 }) +); +floor.rotation.x = -Math.PI / 2; +floor.position.y = -3.4; +floor.receiveShadow = true; +scene.add(floor); + +// --- Reigen ----------------------------------------------------------------- +const reigen = createReigen(); +scene.add(reigen); + +// --- Controls --------------------------------------------------------------- +const controls = new OrbitControls(camera, renderer.domElement); +controls.enableDamping = true; +controls.dampingFactor = 0.08; +controls.minDistance = 2; +controls.maxDistance = 14; +controls.target.copy(SHOTS[0].target); + +// --- Shot switching with a smooth tween ------------------------------------- +let tween = null; // { fromPos, toPos, fromTarget, toTarget, t } + +function goToShot(id) { + const shot = getShot(id); + tween = { + fromPos: camera.position.clone(), + toPos: shot.position.clone(), + fromTarget: controls.target.clone(), + toTarget: shot.target.clone(), + t: 0, + }; + document.querySelectorAll(".shot-btn").forEach((b) => + b.classList.toggle("active", b.dataset.shot === id) + ); +} + +function easeInOut(t) { + return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; +} + +// --- Build shot buttons ----------------------------------------------------- +const shotBar = document.getElementById("shot-bar"); +SHOTS.forEach((shot, i) => { + const btn = document.createElement("button"); + btn.className = "shot-btn" + (i === 0 ? " active" : ""); + btn.textContent = shot.label; + btn.dataset.shot = shot.id; + btn.addEventListener("click", () => goToShot(shot.id)); + shotBar.appendChild(btn); +}); + +// --- Auto-rotate toggle ----------------------------------------------------- +const autoRotateEl = document.getElementById("auto-rotate"); +autoRotateEl.addEventListener("change", () => { + controls.autoRotate = autoRotateEl.checked; + controls.autoRotateSpeed = 1.4; +}); + +// --- Resize ----------------------------------------------------------------- +window.addEventListener("resize", () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +}); + +// --- Render loop ------------------------------------------------------------ +const clock = new THREE.Clock(); + +function animate() { + requestAnimationFrame(animate); + const dt = clock.getDelta(); + + if (tween) { + tween.t = Math.min(1, tween.t + dt / 1.1); // ~1.1s transition + const e = easeInOut(tween.t); + camera.position.lerpVectors(tween.fromPos, tween.toPos, e); + controls.target.lerpVectors(tween.fromTarget, tween.toTarget, e); + if (tween.t >= 1) tween = null; + } + + // Gentle idle breathing so the portrait feels alive when still. + const idle = Math.sin(clock.elapsedTime * 1.1) * 0.015; + reigen.position.y = idle; + reigen.rotation.y = Math.sin(clock.elapsedTime * 0.4) * 0.04; + + controls.update(); + renderer.render(scene, camera); +} + +animate(); diff --git a/js/reigen-character.js b/js/reigen-character.js new file mode 100644 index 0000000..38a104b --- /dev/null +++ b/js/reigen-character.js @@ -0,0 +1,294 @@ +// Procedural Reigen Arataka (Mob Psycho 100). +// Builds his head, face and slicked-back parted hair from Three.js primitives. +// Everything is returned inside a single THREE.Group so shots can frame it. + +import * as THREE from "three"; + +// --- Palette matching Reigen's anime look ----------------------------------- +const COLORS = { + skin: 0xf0c9a4, + skinShadow: 0xd9a97f, + hair: 0xb98a4a, // light gingery brown, slicked back + hairDark: 0x8a6330, + eyeWhite: 0xf7f4ee, + iris: 0x5a4632, // narrow, unimpressed brown eyes + brow: 0x8a6330, + mouth: 0x8a4b45, + suit: 0x2c2c34, + shirt: 0xf4f1ea, + tie: 0x9a2f2f, +}; + +function mat(color, opts = {}) { + return new THREE.MeshStandardMaterial({ + color, + roughness: opts.roughness ?? 0.75, + metalness: opts.metalness ?? 0.05, + ...opts, + }); +} + +// Build one eye assembly (white + iris + upper lid) facing +Z. +// Reigen's signature is half-lidded, narrow eyes, so the lid covers the top. +function buildEye(side) { + const eye = new THREE.Group(); + const dir = side === "left" ? 1 : -1; + + const white = new THREE.Mesh( + new THREE.SphereGeometry(0.16, 24, 24), + mat(COLORS.eyeWhite, { roughness: 0.35 }) + ); + white.scale.set(1.35, 0.85, 0.6); + eye.add(white); + + const iris = new THREE.Mesh( + new THREE.SphereGeometry(0.075, 20, 20), + mat(COLORS.iris, { roughness: 0.3 }) + ); + iris.position.set(0.02 * dir, -0.01, 0.11); + iris.scale.set(1, 1.15, 0.5); + eye.add(iris); + + const pupil = new THREE.Mesh( + new THREE.SphereGeometry(0.032, 16, 16), + mat(0x161210, { roughness: 0.2 }) + ); + pupil.position.set(0.02 * dir, -0.01, 0.15); + pupil.scale.set(1, 1.2, 0.4); + eye.add(pupil); + + // Heavy upper lid — the "done with your nonsense" Reigen stare. + const lid = new THREE.Mesh( + new THREE.SphereGeometry(0.19, 24, 16, 0, Math.PI * 2, 0, Math.PI * 0.55), + mat(COLORS.skin) + ); + lid.scale.set(1.35, 0.9, 0.7); + lid.position.set(0, 0.05, 0.02); + lid.rotation.z = -0.12 * dir; + eye.add(lid); + + return eye; +} + +// A gently curved eyebrow made from a thin, slightly rotated box. +function buildBrow(side) { + const dir = side === "left" ? 1 : -1; + const brow = new THREE.Mesh( + new THREE.BoxGeometry(0.34, 0.045, 0.08), + mat(COLORS.brow, { roughness: 0.6 }) + ); + brow.rotation.z = 0.14 * dir; // slight confident arch + return brow; +} + +// Slicked-back, side-parted hair. Reigen keeps it tidy and swept up/back. +function buildHair() { + const hair = new THREE.Group(); + const hairMat = mat(COLORS.hair, { roughness: 0.55 }); + const hairDarkMat = mat(COLORS.hairDark, { roughness: 0.6 }); + + // Main dome cap covering the crown and back of the head. + const cap = new THREE.Mesh( + new THREE.SphereGeometry(1.06, 40, 40, 0, Math.PI * 2, 0, Math.PI * 0.62), + hairMat + ); + cap.scale.set(1.02, 1.05, 1.06); + cap.position.y = 0.08; + hair.add(cap); + + // Back-of-head volume so the crown isn't flat. + const back = new THREE.Mesh( + new THREE.SphereGeometry(1.0, 32, 32, 0, Math.PI * 2, Math.PI * 0.3, Math.PI * 0.55), + hairMat + ); + back.position.set(0, 0.02, -0.18); + back.scale.set(1.0, 1.0, 1.05); + hair.add(back); + + // Swept-back strands rendered as slim, tapered boxes fanning from the part. + // The part sits slightly to his left; strands sweep up and toward the back. + const strandGeo = new THREE.BoxGeometry(0.14, 0.9, 0.1); + const partX = 0.18; + for (let i = 0; i < 9; i++) { + const t = i / 8; // 0..1 across the forehead + const strand = new THREE.Mesh(strandGeo, i % 2 ? hairDarkMat : hairMat); + const x = partX + (t - 0.5) * 1.7; + strand.position.set(x, 0.86, 0.72 - Math.abs(x) * 0.25); + // Sweep back: tilt tops backward, fan outward from the part. + strand.rotation.x = -0.62; + strand.rotation.z = (x - partX) * 0.32; + strand.scale.y = 1 - Math.abs(x) * 0.12; + hair.add(strand); + } + + // A couple of short front tufts near the part for that neat framed forehead. + for (let i = 0; i < 3; i++) { + const tuft = new THREE.Mesh( + new THREE.ConeGeometry(0.11, 0.5, 8), + hairMat + ); + tuft.position.set(partX - 0.05 + i * 0.16, 0.98, 0.62); + tuft.rotation.x = -0.5; + tuft.rotation.z = -0.2 + i * 0.15; + hair.add(tuft); + } + + // Sideburns / temple hair edges. + [-1, 1].forEach((s) => { + const side = new THREE.Mesh( + new THREE.BoxGeometry(0.12, 0.55, 0.5), + hairMat + ); + side.position.set(0.95 * s, 0.25, 0.05); + side.rotation.z = 0.1 * s; + hair.add(side); + }); + + return hair; +} + +// Suit shoulders + shirt + red tie so head shots read as a portrait. +function buildBust() { + const bust = new THREE.Group(); + + const shoulders = new THREE.Mesh( + new THREE.CylinderGeometry(1.15, 1.55, 0.9, 32, 1, true), + mat(COLORS.suit, { roughness: 0.85, side: THREE.DoubleSide }) + ); + shoulders.position.y = -2.05; + bust.add(shoulders); + + const chest = new THREE.Mesh( + new THREE.SphereGeometry(1.3, 24, 24, 0, Math.PI * 2, 0, Math.PI * 0.5), + mat(COLORS.suit, { roughness: 0.85 }) + ); + chest.position.y = -2.5; + chest.scale.set(1.2, 0.7, 0.9); + bust.add(chest); + + // Shirt V + collar. + const shirt = new THREE.Mesh( + new THREE.ConeGeometry(0.5, 0.9, 3), + mat(COLORS.shirt, { roughness: 0.6 }) + ); + shirt.rotation.y = Math.PI / 4; + shirt.position.set(0, -1.85, 0.55); + bust.add(shirt); + + const tie = new THREE.Mesh( + new THREE.ConeGeometry(0.13, 0.8, 4), + mat(COLORS.tie, { roughness: 0.5 }) + ); + tie.rotation.y = Math.PI / 4; + tie.position.set(0, -2.0, 0.62); + bust.add(tie); + + const knot = new THREE.Mesh( + new THREE.BoxGeometry(0.18, 0.18, 0.12), + mat(COLORS.tie, { roughness: 0.5 }) + ); + knot.rotation.z = Math.PI / 4; + knot.position.set(0, -1.6, 0.6); + bust.add(knot); + + return bust; +} + +/** + * Build the whole Reigen figure. + * @returns {THREE.Group} centered roughly on the face, +Z is forward. + */ +export function createReigen() { + const reigen = new THREE.Group(); + reigen.name = "Reigen"; + + // --- Head: a softened, slightly long face shape --------------------------- + const head = new THREE.Mesh( + new THREE.SphereGeometry(1.0, 48, 48), + mat(COLORS.skin, { roughness: 0.68 }) + ); + head.scale.set(0.92, 1.08, 0.95); + head.castShadow = true; + reigen.add(head); + + // Jaw / chin taper for a more masculine, defined lower face. + const jaw = new THREE.Mesh( + new THREE.SphereGeometry(0.7, 32, 32), + mat(COLORS.skin, { roughness: 0.68 }) + ); + jaw.position.set(0, -0.62, 0.06); + jaw.scale.set(0.95, 0.85, 0.9); + reigen.add(jaw); + + // --- Nose --------------------------------------------------------------- + const nose = new THREE.Mesh( + new THREE.ConeGeometry(0.12, 0.42, 12), + mat(COLORS.skin, { roughness: 0.7 }) + ); + nose.rotation.x = Math.PI / 2.05; + nose.position.set(0, -0.12, 0.96); + reigen.add(nose); + + // --- Eyes --------------------------------------------------------------- + const eyeY = 0.14; + const eyeZ = 0.82; + const eyeX = 0.38; + + const leftEye = buildEye("left"); + leftEye.position.set(eyeX, eyeY, eyeZ); + leftEye.rotation.y = -0.28; + reigen.add(leftEye); + + const rightEye = buildEye("right"); + rightEye.position.set(-eyeX, eyeY, eyeZ); + rightEye.rotation.y = 0.28; + reigen.add(rightEye); + + // --- Brows -------------------------------------------------------------- + const leftBrow = buildBrow("left"); + leftBrow.position.set(eyeX, eyeY + 0.32, eyeZ + 0.12); + leftBrow.rotation.y = -0.28; + reigen.add(leftBrow); + + const rightBrow = buildBrow("right"); + rightBrow.position.set(-eyeX, eyeY + 0.32, eyeZ + 0.12); + rightBrow.rotation.y = 0.28; + reigen.add(rightBrow); + + // --- Mouth: a flat, slightly smug line ---------------------------------- + const mouth = new THREE.Mesh( + new THREE.BoxGeometry(0.4, 0.04, 0.06), + mat(COLORS.mouth, { roughness: 0.5 }) + ); + mouth.position.set(0, -0.58, 0.86); + mouth.rotation.z = -0.04; // faint confident smirk + reigen.add(mouth); + + // Subtle cheek/nasolabial hint via small darker spheres. + [-1, 1].forEach((s) => { + const cheek = new THREE.Mesh( + new THREE.SphereGeometry(0.16, 16, 16), + mat(COLORS.skinShadow, { roughness: 0.8, transparent: true, opacity: 0.35 }) + ); + cheek.position.set(0.42 * s, -0.3, 0.82); + cheek.scale.set(1, 1.3, 0.4); + reigen.add(cheek); + }); + + // --- Ears --------------------------------------------------------------- + [-1, 1].forEach((s) => { + const ear = new THREE.Mesh( + new THREE.SphereGeometry(0.22, 16, 16), + mat(COLORS.skin, { roughness: 0.7 }) + ); + ear.position.set(0.92 * s, -0.05, 0.02); + ear.scale.set(0.45, 0.9, 0.6); + reigen.add(ear); + }); + + // --- Hair + bust -------------------------------------------------------- + reigen.add(buildHair()); + reigen.add(buildBust()); + + return reigen; +}