Back to demo
TECH STACK · DECONSTRUCTED

Super 的笑容 — How it's built

A single 3,891-line HTML file that renders 50 procedurally-generated humanoid avatars, seven full-body animation modes (including a physics-driven tennis rally and a five-state breakdance sequence), all inside the browser with no build step and no framework. This page is a tour of every meaningful technique used.

3,891Lines · 1 file
50Avatars
7Anim modes
120FPS target
0Framework deps

01Rendering Pipeline

WebGL via Three.js — but the postprocessing chain and rig math are all hand-written.

Three.js r128

CDN

Core WebGLRenderer with PCF soft shadow maps, antialias, and a pixel ratio capped at 2 to keep retina screens fast.

UnrealBloomPass

Postprocessing

Loaded as r128 examples (CopyShader · LuminosityHighPassShader · EffectComposer · RenderPass · ShaderPass · UnrealBloomPass). Strength controlled live; composer skipped when strength≈0 to save GPU.

Edge artifact fix

Gotcha

Composer needs setPixelRatio() matching the renderer, otherwise the bloom mip-chain samples 1px outside the texture and paints a white strip on the right edge.

Materials

MeshPhongMaterial

Geometry helpers (mkSphere, mkCyl, mkBox, mkLimb) clone a Phong base material with emissive on glow parts (eyes, chest panel, joints, ball sweet-spot) so bloom has something to grab.

Lighting

3-point + fog

Directional sun (shadow caster), ambient blue, two coloured point lights. Each environment preset retunes colour temperature, intensity and FogExp2 density together.

Floor modes

Live swap

Single PlaneGeometry whose Phong shininess and color are mutated to fake Grid / Mirror / Matte / Hide states without rebuilding meshes.

02Procedural Rig

No GLTF import — every limb is a nested THREE.Group tree assembled at runtime.

Nested groups as bones

Hierarchy

root → spine → neck → head, plus shoulder → elbow → hand and hip → knee → ankle → foot. Each Group's local rotation = a joint angle; child position = bone length. Pure forward kinematics.

50 procedural avatars

Combinatorics

Each character is a config object indexing into 10 skin tones × 13 hair colours × 11 hair styles × 15 shirts × 10 pants × 8 panels × 8 eyes + accessory + scale. Index hashes use coprime stride multipliers (3, 7, 11, 13, 17) for visual diversity.

Vitruvian proportions

Anatomy

Shoulder X = 0.50 of body height; upper arm 0.66 + forearm 0.55 ≈ 32% of body height. Head scaled 0.79 to land near 6.5-head adult proportions.

Penetration clamps

穿模 防護

clampArmsThroughBody tests hand / elbow / forearm-midpoint positions in spine-local space against a torso ellipsoid; if inside, pushes outward along Z. clampLegsThroughBody does the same with knees + feet against a body ellipsoid and a knee-cross check.

Floor pinning

Soft IK

applyGroundCollision probes ankles/feet world Y; if any point sinks below floor level, the whole root translates up just enough to bring the lowest point back to ground. Skipped during break-mode floor work where the contact point is choreographed directly.

Tennis racket

Composite

Built once and parented to rHnd. Has a userData.sweet world-space marker (ball strike origin) that the swing resolver reads to compute exit velocity.

03Animation Math

Every pose is a function of t (time) and clamped state — no keyframe storage, no skeletal blend trees.

Idle / Walk / Run

sin(ωt)

Cyclic motion driven by sine waves with hand-tuned ω per mode. Walk uses ω=2.8 with phase-locked shoulder counter-swing; Run jumps to ω=6.4 plus a vertical lift ∝ |sin(2ωt)|.

rHip.x = +sin(ωt) × 0.52
lHip.x = −sin(ωt) × 0.52
rKn.x  = max(0,+sin(ωt)) × 0.72

Wave / Dance

Composite

Wave is one shoulder lift + a high-frequency forearm waggle. Dance layers a beat envelope |sin(ωt/2)|^0.6 on top of body sway, neck head-bob and arm pump.

Break: 5-state machine

Choreography

Toprock → Windmill → Headspin → Freeze → Get-up. Each state is its own pose function; transitions happen at fixed time stamps. Windmill skips the leg-clamp because legs legitimately sweep through the body-local torso volume each rotation.

Tennis rally physics

Real ballistics

Ball updates with gravity 9.81 m/s², 0.999 drag per tick, vertical bounce coefficient −0.62, ground-skid coefficient 0.93. Shot variants (flat / topspin / slice / lob / drop / cross) just pick a target apex height.

v.y' = v.y − g · dt
v   = v · 0.999            (drag)
on bounce: v.y ← −0.62·v.y
           v.xz ← 0.93·v.xz

Trajectory prediction

AI

predictBall(dtAhead, …) rolls the ball physics forward in a fixed-step loop. predictIntercept(side) walks the prediction until the ball crosses the player's baseline plane, returning {time, position} → AI runs to it.

v_y₀ = √(2g·(h−y₀))
T    = (v_y₀+√(v_y₀²+2g·y_x))/g
v_x  = (xT−x₀) / T
v_z  = (zT−z₀) / T

Serve kinetic chain

Biomechanics

Trophy → drive → strike → follow-through. Phases drive shoulder, elbow and torso angles in sequence, magnetising the ball to the racket sweet-spot at strike t.

04UI System

Glassmorphism rendered with native CSS — no UI framework, no icon library.

Glassmorphism

CSS only

Every panel uses backdrop-filter: blur(22px) saturate(160%) over a translucent background, with rgba strokes for the rim-light effect. Falls back gracefully on browsers without backdrop-filter.

Theme tokens

CSS Vars

Four environment themes (Cyber Night · Daylight · Sunset · Arena) are pure variable swaps on body.theme-*. The same swap also retunes Three.js lights & fog so 2D and 3D stay consistent.

Inline SVG icons

Pixel-perfect

Earlier versions used unicode glyphs (⏸ ⌖ 📸 ⚙ etc) but per-OS font metrics offset them off-centre inside circular buttons. Switching to fill="currentColor" SVG paths fixes alignment and lets the icon inherit hover / active colour.

Custom select chevron

Cross-browser

Native select arrows render at different positions per platform. Inline SVG data-URI background-image + appearance:none gives identical visuals everywhere.

localStorage v1

Persistence

Single versioned key 3dhuman.settings.v1 stores env, floor, FOV, speed, mode, char A/B, formula collapse, bloom strength and per-character colour overrides as a diff. Restored on init in dependency order.

scroll-padding-top

Subtle bug

Formula panel uses scrollIntoView({block:'start'}) to keep the active block visible. To avoid the floating 𝑓 toggle covering its top edge, the panel has scroll-padding-top instead of regular padding-top.

05Input & Capture

Keyboard, mouse and screen recording — all in browser native APIs.

Camera orbit

Spherical

Camera position computed from (R, θ, φ). Mouse drag updates θ/φ; wheel updates R, both with explicit clamps so the camera never enters the floor or flips upside-down.

WASD / QE state machine

Per-frame

Keydown flips a flag in camKeys, keyup clears it. A separate rAF tick integrates flag × dt every frame so movement is smooth regardless of OS key-repeat rate.

Screenshot

toBlob

Force a render → renderer.domElement.toBlob('image/png') → download via dynamic <a>. No server round-trip.

Video recording

MediaRecorder

canvas.captureStream(60) piped into MediaRecorder with VP9 / VP8 fallback and 8 Mbps bitrate. Chunks collected on dataavailable, blob saved on stop.

Hotkey hygiene

Gotcha

Pressing C used to open the avatar grid and type "c" into the now-focused search field. Fix: e.preventDefault() for any letter hotkey that focuses an input.

Time scrub

timeScale

Single animClock advances by realDt × timeScale. Pause freezes the clock; the speed slider stretches it; the step button advances 1/60 s while paused. Every animation function trusts the clock.

06Performance Notes

Animation runs at ~120 FPS on a recent MacBook even with bloom + shadows.

07Why no framework?

An explicit anti-decision worth documenting.

The scope didn't justify it

Honest

Single-page demo. Zero routing, zero shared component reuse, no team. A framework here would be 100% overhead and 0% benefit.

No build step

DX

One index.html. Open in any browser via file:// or any static host. No npm install, no Vite config, no bundle hash. Source view shows everything.

What would framework adoption buy?

Tradeoff

Reactive UI bindings — but the chrome here is small enough that addEventListener + manual DOM updates take 30 lines instead of 300 lines + library. Component re-use — N/A here.

What it would cost

Risk

~3,900 lines of working code to migrate, hours of regression testing on the tennis physics and break-state machine, plus a build pipeline to maintain forever.