mirror of
https://github.com/tiennm99/reigen.git
synced 2026-09-03 20:22:46 +00:00
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.
This commit is contained in:
@@ -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
|
||||
@@ -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**.
|
||||
|
||||
+134
@@ -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;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Reigen Arataka — Three.js</title>
|
||||
<link rel="stylesheet" href="./css/style.css" />
|
||||
<!-- Three.js is loaded via an import map so main.js can `import ... from "three"` -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<canvas id="scene"></canvas>
|
||||
|
||||
<header id="titlebar">
|
||||
<h1>霊幻 新隆 · Reigen Arataka</h1>
|
||||
<p>The Greatest Psychic of the 21st Century — built with Three.js</p>
|
||||
</header>
|
||||
|
||||
<div id="shot-bar" role="toolbar" aria-label="Camera shots"></div>
|
||||
|
||||
<footer id="hint">
|
||||
<span>Drag to orbit · scroll to zoom</span>
|
||||
<label><input type="checkbox" id="auto-rotate" /> Auto-rotate</label>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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];
|
||||
}
|
||||
+143
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user