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:
2026-07-18 19:27:46 +07:00
parent 259bdf4871
commit 5ffb7cdfdc
8 changed files with 753 additions and 2 deletions
+39
View File
@@ -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
View File
+45 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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>霊幻 新隆 &middot; 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 &middot; 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>
+61
View File
@@ -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
View File
@@ -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();
+294
View File
@@ -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;
}