Files
tilt/src/character/skaterGear.js
T
2026-08-03 06:43:21 -05:00

694 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from 'three';
import { mergeGeoms } from '../core/math.js';
import { PART } from './body.js';
import { computeSkin } from './skinning.js';
import { carvedShell, loft, mergeBars, tint, tube } from './gearMesh.js';
/**
* Skater equipment, in layers.
*
* A hockey player is dressed, not painted, and the order is the order it goes
* on in a dressing room:
*
* 1. shoulder pads and elbow caps — the under layer that gives the torso its
* shape. Mostly hidden, which is the point: the jersey drapes over it.
* 2. jersey — long sleeves, hem past the waist, cut wide enough to clear the
* pads underneath.
* 3. pants — waist-high padded shorts down to just above the knee.
* 4. socks over shin guards, taped at the top and bottom of the wrap.
* 5. skates, gloves, helmet.
*
* ### Skinned vs socketed
*
* Anything that crosses a joint is skinned to the same skeleton the body uses
* (`computeSkin`, then bound as a second SkinnedMesh sharing `skelData`). A
* jersey bolted to the chest bone tears open at the shoulder the first time an
* arm swings; a pant leg bolted to the pelvis passes through the thigh on a
* knee bend. Cloth is authored in rest space, exactly like the body geometry.
*
* Boots, gloves and the helmet are rigid shells that genuinely do not bend, so
* they are socketed to the foot, hand and head bones and cost nothing to skin.
*
* ### Fit
*
* Every radius scales off the physique factors the body loft was built from
* (`bodyGeo.userData.physique`), so a heavy build gets a bigger jersey instead
* of wearing its chest through the front of it.
*/
/** Rest direction the upper arm points, in its own bone space (A-pose). */
const ARM_DIR = {
L: new THREE.Vector3(0.15, -0.252, 0.01).normalize(),
R: new THREE.Vector3(-0.15, -0.252, 0.01).normalize(),
};
/** Rest direction the fingers point, from the hand bone. */
const HAND_DIR = {
L: new THREE.Vector3(0.045, -0.095, 0.008).normalize(),
R: new THREE.Vector3(-0.045, -0.095, 0.008).normalize(),
};
const DOWN = new THREE.Vector3(0, -1, 0);
export const KIT = {
helmet: {
/** Skull centre in head-bone-local space. */
riseY: 0.094,
pushZ: -0.004,
rx: 0.114,
ry: 0.148,
rz: 0.125,
/**
* Polar angle the shell starts at. This is the number that decides whether
* you get a helmet or a beanie: the bottom ring sits at
* riseY ry·cos(phi0), so it has to come out *below* the ear line.
*/
phi0: 0.36,
wall: 0.009,
/** Brow line: everything in front of and below this is open face. */
browY: 0.03,
earY: -0.022,
},
/** Blade bottom, in foot-bone-local metres. Feet plant at y ≈ 0.09. */
bladeY: -0.09,
};
/**
* What the kit covers, as `aT` ranges per body part.
*
* The body underneath a dressed skater is wasted work and a source of
* poke-through: a shoulder rolls, a hip flexes, and a sliver of the layer below
* pushes through a seam. Ludus solved it by dropping the covered body faces
* once the clothing went on, and the same applies here.
*
* Ranges are deliberately short of the seams. A triangle is only dropped when
* *all three* of its vertices are covered, which leaves a one-triangle fringe
* under every edge of the gear — cheap insurance against a gap opening up at
* the collar or the cuff when the pose moves.
*/
export const COVERAGE = {
// Jersey and pants, up to the collar. The neck and above stay.
[PART.TORSO]: [0.0, 0.9],
// Sleeve and glove, deltoid to fingertips. The shoulder ball has to be in
// here: it is the widest thing on the arm and it sits exactly where the
// sleeve meets the yoke, so leaving it visible shows it through the seam.
[PART.ARM_L]: [0.0, 1.0],
[PART.ARM_R]: [0.0, 1.0],
// Pants, socks and boots enclose the leg end to end.
[PART.LEG_L]: [0.0, 1.0],
[PART.LEG_R]: [0.0, 1.0],
};
/**
* Drop the body faces the kit covers. Call after `computeSkin` and after the
* body has been painted — it only rewrites the index.
*/
export function hideCoveredBody(geo, coverage = COVERAGE) {
const partAttr = geo.attributes.aPart;
const tAttr = geo.attributes.aT;
if (!partAttr || !tAttr || !geo.index) return geo;
const covered = (v) => {
const range = coverage[partAttr.getX(v)];
if (!range) return false;
const t = tAttr.getX(v);
return t >= range[0] && t <= range[1];
};
const idx = geo.index.array;
const keep = [];
for (let f = 0; f < idx.length; f += 3) {
const a = idx[f];
const b = idx[f + 1];
const c = idx[f + 2];
if (covered(a) && covered(b) && covered(c)) continue;
keep.push(a, b, c);
}
geo.setIndex(keep);
return geo;
}
/**
* @param {*} mats from `buildSkaterGearMaterials`
* @param {*} skelData the skeleton the cloth binds to
* @param {{bulk:number,waistF:number,shoulderF:number,armF:number,legF:number,headF:number}} phys
*/
export function buildSkaterGear(mats, skelData, phys) {
const pieces = [];
const skinned = [];
const disposables = [];
const bulk = phys?.bulk ?? 1;
const shoulder = (phys?.shoulderF ?? 1) * bulk;
const waist = (phys?.waistF ?? 1) * bulk;
const armF = phys?.armF ?? 1;
const legF = phys?.legF ?? 1;
const headF = phys?.headF ?? 1;
const PAL = {
jersey: tint(mats.jersey.color),
accent: tint(mats.accent.color),
trim: tint(mats.trim.color),
pad: tint(mats.pad.color),
tape: tint(mats.tape.color),
};
const V = (x, y, z = 0) => new THREE.Vector3(x, y, z);
const S = (c, rx, rz, e, col) => ({ c, rx, rz, e, col });
function mesh(geo, mat, name) {
const m = new THREE.Mesh(geo, mat);
m.name = name;
m.castShadow = true;
m.receiveShadow = true;
disposables.push(geo);
return m;
}
/** Point a group's Y down a bone's real limb direction. */
function alignTo(group, dir) {
group.quaternion.setFromUnitVectors(DOWN, dir);
return group;
}
/**
* Merge rest-space pieces, solve skin weights, and bind to the body's
* skeleton. `computeSkin` overwrites the colour attribute with its debug
* heatmap, so the kit colours are stashed and put back afterwards — same
* dance `paintKit` does for the body.
*/
function skin(parts, mat, name) {
const geo = mergeGeoms(parts);
for (const p of parts) p.dispose();
const colors = geo.attributes.color.array.slice();
computeSkin(geo, skelData);
geo.userData.heatColors = geo.attributes.color.array.slice();
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geo.computeVertexNormals();
const m = new THREE.SkinnedMesh(geo, mat);
m.name = name;
m.castShadow = true;
m.receiveShadow = true;
m.frustumCulled = false;
// Bound before parenting, so the bind matrix is identity — matching the
// body mesh. The root bone stays parented to the body; a second mesh only
// borrows the skeleton.
m.updateMatrixWorld(true);
m.bind(skelData.skeleton, m.matrixWorld.clone());
disposables.push(geo);
skinned.push(m);
pieces.push(m);
return m;
}
// ---- 1. under layer: shoulder pads -------------------------------------
// Sits between skin and jersey. Barely seen, but it is what makes the jersey
// sit square across the shoulders instead of shrink-wrapping the deltoids.
// Kept a clear centimetre inside the jersey at every ring. Two skinned
// meshes never deform identically — their vertices sit in different places,
// so the distance-field solve hands them different weights — and a pad that
// merely *touches* the inside of a sweater will tear through it on a shoulder
// roll. What actually shows is the collar, standing above the neckline.
const padChest = loft([
S(V(0, 1.18, 0.006), 0.156 * bulk, 0.108 * bulk, 4, PAL.pad),
S(V(0, 1.26, 0.008), 0.17 * shoulder, 0.116 * bulk, 4),
S(V(0, 1.335, 0.008), 0.186 * shoulder, 0.12 * bulk, 4),
S(V(0, 1.392, 0.01), 0.16 * shoulder, 0.106 * bulk, 4),
S(V(0, 1.428, 0.012), 0.1 * bulk, 0.09 * bulk, 3),
S(V(0, 1.452, 0.013), 0.094 * bulk, 0.085 * bulk, 3),
], { radial: 16, sub: 3, part: PART.TORSO, t0: 0.5, t1: 0.96 });
skin([padChest], mats.padded, 'shoulderPads');
// Deltoid caps ride the upper arms so they follow the shoulder, not the ribs.
function makeCap(side) {
const g = new THREE.Group();
g.name = `shoulderCap${side}`;
// Kept under the sleeve radius at every ring: the cap is rigid on the bone
// and the sleeve is skinned, so anything close to the same size pushes
// through the cloth the moment the arm swings.
const cap = loft([
S(V(0, 0.04, 0.008), 0.062 * armF, 0.058 * armF, 3, PAL.pad),
S(V(0, -0.025, 0.01), 0.074 * armF, 0.07 * armF, 4),
S(V(0, -0.09, 0.008), 0.068 * armF, 0.064 * armF, 4),
S(V(0, -0.14, 0.006), 0.054 * armF, 0.05 * armF, 3),
], { radial: 14, sub: 3 });
g.add(mesh(cap, mats.padded, `shoulderCap${side}Shell`));
alignTo(g, ARM_DIR[side]);
pieces.push(g);
return g;
}
const capL = makeCap('L');
const capR = makeCap('R');
// ---- 2. jersey ----------------------------------------------------------
// Torso plus two long sleeves, merged into one skinned mesh. Waist stripes
// and cuff bands are cut the same way the goalie's pad bands are: two
// sections a centimetre apart.
const jerseyParts = [];
jerseyParts.push(loft([
// Hem hangs over the pants, so it has to clear the widest part of them.
S(V(0, 0.878, 0.004), 0.226 * bulk, 0.17 * bulk, 4, PAL.jersey),
S(V(0, 0.905, 0.004), 0.232 * bulk, 0.174 * bulk, 4, PAL.accent),
S(V(0, 0.94, 0.004), 0.233 * bulk, 0.175 * bulk, 4),
S(V(0, 0.95, 0.004), 0.232 * bulk, 0.174 * bulk, 4, PAL.trim),
S(V(0, 0.98, 0.005), 0.229 * bulk, 0.171 * bulk, 4),
S(V(0, 0.99, 0.005), 0.228 * bulk, 0.17 * bulk, 4, PAL.jersey),
S(V(0, 1.075, 0.005), 0.207 * waist, 0.152 * waist, 4),
S(V(0, 1.165, 0.007), 0.202 * bulk, 0.148 * bulk, 4),
S(V(0, 1.255, 0.009), 0.212 * bulk, 0.155 * bulk, 4),
// Over the shoulder pads — the widest point of a dressed player.
S(V(0, 1.335, 0.01), 0.242 * shoulder, 0.16 * bulk, 5),
S(V(0, 1.395, 0.012), 0.222 * shoulder, 0.142 * bulk, 4),
S(V(0, 1.418, 0.013), 0.17 * shoulder, 0.12 * bulk, 4),
S(V(0, 1.432, 0.013), 0.108 * bulk, 0.098 * bulk, 3, PAL.trim),
S(V(0, 1.462, 0.014), 0.098 * bulk, 0.09 * bulk, 3),
], { radial: 20, sub: 3, part: PART.TORSO, t0: 0.0, t1: 0.98 }));
for (const side of ['L', 'R']) {
const s = side === 'L' ? 1 : -1;
const P = (x, y, z = 0) => V(s * x, y, z);
jerseyParts.push(loft([
// Wide enough at the top to swallow the deltoid ball, and buried in the
// torso shell so the shoulder seam never opens.
S(P(0.10, 1.415, 0.008), 0.108 * armF, 0.10 * armF, 3, PAL.jersey),
S(P(0.175, 1.385, 0.01), 0.118 * armF, 0.112 * armF, 3),
S(P(0.245, 1.325, 0.01), 0.105 * armF, 0.10 * armF, 3),
S(P(0.30, 1.27, 0.01), 0.09 * armF, 0.086 * armF, 3),
S(P(0.355, 1.16, 0.012), 0.072 * armF, 0.068 * armF, 3),
// Elbow cap under the sleeve.
S(P(0.397, 1.095, 0.013), 0.076 * armF, 0.072 * armF, 3),
S(P(0.447, 0.985, 0.016), 0.064 * armF, 0.06 * armF, 3),
S(P(0.472, 0.93, 0.018), 0.058 * armF, 0.055 * armF, 3, PAL.accent),
S(P(0.487, 0.898, 0.02), 0.057 * armF, 0.054 * armF, 3),
S(P(0.497, 0.876, 0.022), 0.056 * armF, 0.053 * armF, 3, PAL.trim),
S(P(0.512, 0.844, 0.024), 0.053 * armF, 0.05 * armF, 3),
], {
radial: 14,
sub: 3,
part: side === 'L' ? PART.ARM_L : PART.ARM_R,
t0: 0.1,
t1: 0.94,
}));
}
skin(jerseyParts, mats.cloth, 'jersey');
// ---- 3. pants -----------------------------------------------------------
// Waist-high padded shorts: a hip shell plus two thigh tubes that stop above
// the knee. Stiff, so they are wide and barely taper.
const pantParts = [];
pantParts.push(loft([
S(V(0, 1.115, 0.004), 0.178 * waist, 0.132 * waist, 4, PAL.trim),
S(V(0, 1.09, 0.004), 0.186 * waist, 0.138 * waist, 4),
S(V(0, 1.08, 0.004), 0.19 * waist, 0.142 * waist, 4, PAL.accent),
S(V(0, 1.055, 0.005), 0.196 * waist, 0.146 * waist, 4),
S(V(0, 1.045, 0.005), 0.198 * waist, 0.148 * waist, 4, PAL.trim),
S(V(0, 0.99, 0.005), 0.205 * bulk, 0.152 * bulk, 5),
S(V(0, 0.94, 0.005), 0.207 * bulk, 0.154 * bulk, 5),
S(V(0, 0.90, 0.004), 0.198 * bulk, 0.146 * bulk, 5),
], { radial: 18, sub: 3, part: PART.TORSO, t0: 0.02, t1: 0.34 }));
for (const side of ['L', 'R']) {
const s = side === 'L' ? 1 : -1;
const P = (x, y, z = 0) => V(s * x, y, z);
pantParts.push(loft([
S(P(0.098, 0.97, 0.004), 0.142 * legF, 0.132 * legF, 4, PAL.trim),
S(P(0.112, 0.90, 0.006), 0.138 * legF, 0.13 * legF, 4),
S(P(0.12, 0.80, 0.008), 0.13 * legF, 0.122 * legF, 4),
S(P(0.126, 0.71, 0.008), 0.122 * legF, 0.114 * legF, 4),
S(P(0.127, 0.688, 0.008), 0.119 * legF, 0.111 * legF, 4, PAL.accent),
S(P(0.128, 0.668, 0.008), 0.116 * legF, 0.108 * legF, 4),
S(P(0.1285, 0.658, 0.008), 0.114 * legF, 0.106 * legF, 4, PAL.trim),
S(P(0.129, 0.645, 0.008), 0.112 * legF, 0.104 * legF, 4),
], {
radial: 14,
sub: 3,
part: side === 'L' ? PART.LEG_L : PART.LEG_R,
t0: 0.02,
t1: 0.34,
}));
}
skin(pantParts, mats.padded, 'pants');
// ---- 4. socks over shin guards -----------------------------------------
// The sock is the visible layer; the guard underneath is read as the bulge at
// the knee and the flat down the front of the shin. Tape bands at the top and
// bottom of the wrap, where a player actually tapes.
const sockParts = [];
for (const side of ['L', 'R']) {
const s = side === 'L' ? 1 : -1;
const part = side === 'L' ? PART.LEG_L : PART.LEG_R;
const P = (x, y, z = 0) => V(s * x, y, z);
sockParts.push(loft([
S(P(0.124, 0.735, 0.008), 0.098 * legF, 0.094 * legF, 3, PAL.jersey),
S(P(0.128, 0.66, 0.01), 0.094 * legF, 0.09 * legF, 3),
// Tape at the top of the wrap.
S(P(0.129, 0.638, 0.01), 0.093 * legF, 0.089 * legF, 3, PAL.tape),
S(P(0.13, 0.60, 0.012), 0.092 * legF, 0.088 * legF, 3),
S(P(0.13, 0.578, 0.012), 0.092 * legF, 0.088 * legF, 3, PAL.jersey),
// Knee.
S(P(0.131, 0.53, 0.016), 0.096 * legF, 0.094 * legF, 3),
S(P(0.132, 0.45, 0.014), 0.086 * legF, 0.082 * legF, 3),
S(P(0.133, 0.35, 0.01), 0.079 * legF, 0.074 * legF, 3),
S(P(0.133, 0.26, 0.006), 0.072 * legF, 0.066 * legF, 3),
// Tape at the bottom of the wrap.
S(P(0.133, 0.232, 0.005), 0.07 * legF, 0.064 * legF, 3, PAL.tape),
S(P(0.132, 0.20, 0.004), 0.068 * legF, 0.062 * legF, 3),
S(P(0.132, 0.18, 0.003), 0.066 * legF, 0.06 * legF, 3, PAL.jersey),
S(P(0.131, 0.135, 0.002), 0.06 * legF, 0.056 * legF, 3),
S(P(0.131, 0.105, 0.004), 0.056 * legF, 0.052 * legF, 3, PAL.trim),
], { radial: 14, sub: 3, part, t0: 0.30, t1: 0.87 }));
// Knee cap: a dome off the front of the wrap.
sockParts.push(loft([
S(P(0.131, 0.545, 0.02), 0.062 * legF, 0.058 * legF, 3, PAL.jersey),
S(P(0.131, 0.542, 0.058), 0.07 * legF, 0.066 * legF, 3),
S(P(0.131, 0.538, 0.088), 0.058 * legF, 0.054 * legF, 3),
S(P(0.131, 0.534, 0.104), 0.03 * legF, 0.028 * legF, 3),
], { radial: 14, sub: 3, part, t0: 0.48, t1: 0.54 }));
}
skin(sockParts, mats.cloth, 'socks');
// ---- 5. skates ----------------------------------------------------------
// Foot-bone local: +Z is forward past the toe, the sole sits a little under
// the bone, the blade hangs where the ice is.
function makeSkate(side) {
const g = new THREE.Group();
g.name = `skate${side}`;
const boot = loft([
S(V(0, -0.014, -0.088), 0.036, 0.042, 4, PAL.trim),
S(V(0, -0.02, -0.05), 0.046, 0.05, 4),
S(V(0, -0.026, 0.01), 0.05, 0.048, 4),
S(V(0, -0.03, 0.07), 0.048, 0.042, 4),
S(V(0, -0.034, 0.125), 0.04, 0.032, 4),
S(V(0, -0.038, 0.162), 0.022, 0.018, 3),
], { radial: 16, sub: 4 });
g.add(mesh(boot, mats.hard, `skate${side}Boot`));
// Ankle cuff — the kit stops at the ankle, as asked.
const cuff = loft([
S(V(0, -0.012, -0.05), 0.048, 0.05, 4, PAL.trim),
S(V(0, 0.03, -0.045), 0.05, 0.048, 4),
S(V(0, 0.062, -0.038), 0.047, 0.044, 4, PAL.pad),
S(V(0, 0.078, -0.032), 0.041, 0.038, 3),
], { radial: 14, sub: 3 });
g.add(mesh(cuff, mats.hard, `skate${side}Cuff`));
// Tongue up the front of the ankle.
const tongue = loft([
S(V(0, -0.01, 0.03), 0.03, 0.014, 3, PAL.trim),
S(V(0, 0.03, 0.012), 0.033, 0.015, 3),
S(V(0, 0.07, 0.0), 0.031, 0.014, 3, PAL.accent),
], { radial: 10, sub: 3 });
g.add(mesh(tongue, mats.hard, `skate${side}Tongue`));
// Holder: two posts off the sole down to the runner.
const holder = [];
for (const z of [-0.045, 0.085]) {
holder.push(tube([
V(0, -0.05, z),
V(0, -0.062, z + (z < 0 ? 0.008 : -0.008)),
V(0, -0.072, z + (z < 0 ? 0.012 : -0.012)),
], 0.011, { radial: 6 }));
}
holder.push(tube([
V(0, -0.073, -0.075), V(0, -0.076, 0), V(0, -0.073, 0.13),
], 0.008, { radial: 6 }));
g.add(mesh(mergeBars(holder), mats.holder, `skate${side}Holder`));
// Runner: a thin steel blade with the toe and heel curling up off the ice.
const blade = loft([
S(V(0, KIT.bladeY + 0.028, -0.108), 0.0035, 0.012, 3, PAL.trim),
S(V(0, KIT.bladeY + 0.012, -0.088), 0.0035, 0.013, 3),
S(V(0, KIT.bladeY + 0.012, 0.12), 0.0035, 0.013, 3),
S(V(0, KIT.bladeY + 0.03, 0.145), 0.0035, 0.012, 3),
], { radial: 6, sub: 4 });
g.add(mesh(blade, mats.steel, `skate${side}Blade`));
// Laces.
const laces = [];
for (const y of [0.0, 0.022, 0.044]) {
laces.push(tube([
V(-0.03, y - 0.005, 0.03 - y * 0.4),
V(0, y + 0.004, 0.022 - y * 0.4),
V(0.03, y - 0.005, 0.03 - y * 0.4),
], 0.004, { radial: 5 }));
}
g.add(mesh(mergeBars(laces), mats.lace, `skate${side}Laces`));
pieces.push(g);
return g;
}
const skateL = makeSkate('L');
const skateR = makeSkate('R');
// ---- 6. gloves ----------------------------------------------------------
// Glove space: fingers down Y, back of the hand +Z, then rotated onto the
// hand bone's real axis. The stick is aimed from the same bone, so the glove
// has to stay a shell around the hand and not swallow the shaft.
function makeGlove(side) {
const s = side === 'L' ? 1 : -1;
const g = new THREE.Group();
g.name = `glove${side}`;
const body = loft([
// Flared cuff roll at the wrist.
S(V(0, 0.085, -0.004), 0.056, 0.054, 3, PAL.trim),
S(V(0, 0.062, -0.002), 0.068, 0.064, 3, PAL.accent),
S(V(0, 0.03, 0.002), 0.074, 0.068, 3),
S(V(0, 0.012, 0.004), 0.076, 0.07, 3, PAL.jersey),
S(V(0, -0.04, 0.01), 0.08, 0.068, 4),
S(V(0, -0.105, 0.014), 0.082, 0.066, 4),
S(V(0, -0.16, 0.014), 0.076, 0.06, 4),
S(V(0, -0.19, 0.012), 0.062, 0.05, 4, PAL.trim),
S(V(0, -0.215, 0.008), 0.042, 0.034, 3),
], { radial: 16, sub: 4 });
g.add(mesh(body, mats.hard, `glove${side}Body`));
// Backhand rolls — the padded ridges across the knuckles.
for (const [y, r] of [[-0.06, 0.026], [-0.115, 0.024]]) {
const roll = loft([
S(V(-s * 0.058, y + 0.012, 0.05), r * 0.8, r * 0.7, 3, PAL.accent),
S(V(0, y, 0.062), r, r * 0.9, 3),
S(V(s * 0.058, y + 0.012, 0.05), r * 0.8, r * 0.7, 3),
], { radial: 10, sub: 4 });
g.add(mesh(roll, mats.hard, `glove${side}Roll`));
}
// Thumb, curling toward the shaft.
const thumb = loft([
S(V(s * 0.058, -0.005, 0.03), 0.03, 0.028, 3, PAL.jersey),
S(V(s * 0.09, -0.065, 0.052), 0.028, 0.026, 3),
S(V(s * 0.092, -0.12, 0.066), 0.023, 0.022, 3, PAL.trim),
], { radial: 10, sub: 4 });
g.add(mesh(thumb, mats.hard, `glove${side}Thumb`));
alignTo(g, HAND_DIR[side]);
g.rotateY(s * 0.25);
pieces.push(g);
return g;
}
const gloveL = makeGlove('L');
const gloveR = makeGlove('R');
// ---- 7. helmet ----------------------------------------------------------
// Same carved-shell builder as the goalie mask, cut differently: the whole
// lower front is open face, with ear ports at the sides.
const H = KIT.helmet;
const skull = new THREE.Vector3(0, H.riseY, H.pushZ);
function helmetSurface(theta, v, out) {
const phi = H.phi0 + (Math.PI - H.phi0) * v;
const sp = Math.sin(phi);
const cp = Math.cos(phi);
const f = Math.cos(theta);
const sx = Math.sin(theta);
const front = Math.max(0, f);
const back = Math.max(0, -f);
let rx = H.rx * headF;
let rz = H.rz * headF;
// Occipital shell carries out over the back of the skull.
rz *= 1 + 0.10 * back * v;
// Slight flat across the forehead.
rz *= 1 - 0.06 * front * front * v;
const x = rx * sp * sx;
const y = -H.ry * headF * cp;
let z = rz * sp * f;
// Brow lip juts forward over the eyes.
const lip = Math.exp(-(((v - 0.08) / 0.12) ** 2)) * front ** 2;
z += 0.008 * lip;
return out.set(skull.x + x, skull.y + y, skull.z + z);
}
/** Open face below the brow, plus a port over each ear. */
const helmetPort = (p) => {
const dy = p.y - skull.y;
const dz = p.z - skull.z;
const ax = Math.abs(p.x);
// The face: front-centre below the brow. Narrow, so the shell keeps its
// cheek coverage instead of turning into a cap.
if (dz > 0.028 && dy < H.browY && ax < 0.072) return true;
// Ear ports, covered by the cups.
if (ax > 0.088 && dy < H.earY + 0.026 && dy > H.earY - 0.042 && Math.abs(dz + 0.014) < 0.038) {
return true;
}
return false;
};
const helmetColor = (p, kind) => {
if (kind === 'inner') return PAL.pad;
const dy = p.y - skull.y;
// Dark brim around the bottom edge of the shell.
if (dy < -0.028) return PAL.trim;
// Centre stripe over the crown.
if (Math.abs(p.x) < 0.019 && dy > 0.03) return PAL.accent;
return PAL.jersey;
};
const helmet = new THREE.Group();
helmet.name = 'helmet';
helmet.add(mesh(
carvedShell({
rows: 26,
cols: 36,
thickness: H.wall,
center: skull,
surface: helmetSurface,
port: helmetPort,
color: helmetColor,
}),
mats.hard,
'helmetShell',
));
// Ear cups over the ports, on their own straps.
for (const s of [1, -1]) {
const cup = loft([
S(V(s * 0.09, skull.y + H.earY, skull.z - 0.014), 0.028, 0.026, 3, PAL.trim),
S(V(s * 0.104, skull.y + H.earY, skull.z - 0.014), 0.03, 0.028, 3),
S(V(s * 0.111, skull.y + H.earY, skull.z - 0.014), 0.023, 0.021, 3),
], { radial: 12, sub: 3, ref: new THREE.Vector3(0, 1, 0) });
helmet.add(mesh(cup, mats.hard, 'helmetEar'));
}
// Chin strap under the jaw.
helmet.add(mesh(
tube([
V(-0.105, skull.y + H.earY - 0.012, skull.z - 0.01),
V(-0.07, skull.y - 0.12, skull.z + 0.03),
V(0, skull.y - 0.145, skull.z + 0.05),
V(0.07, skull.y - 0.12, skull.z + 0.03),
V(0.105, skull.y + H.earY - 0.012, skull.z - 0.01),
], 0.006, { radial: 6 }),
mats.strap,
'helmetStrap',
));
// Half visor: eye level only. Run it down over the whole face and the player
// reads as a welder.
{
const arc = [];
for (let i = 0; i <= 10; i++) {
const a = -0.82 + (1.64 * i) / 10;
arc.push(V(
Math.sin(a) * 0.106 * headF,
skull.y + 0.004,
skull.z + Math.cos(a) * 0.116 * headF,
));
}
// The ring axes here are u = up, w = front-to-back, so `rx` is the shield's
// height and `rz` is its thickness. Swap those two and you get a shelf
// sticking out of the face instead of a shield hanging over the eyes.
const visor = loft(
arc.map((c, i) => S(c, i === 0 || i === arc.length - 1 ? 0.026 : 0.038, 0.003, 3)),
{ radial: 8, sub: 2, ref: new THREE.Vector3(0, 1, 0) },
);
helmet.add(mesh(visor, mats.visor, 'helmetVisor'));
}
pieces.push(helmet);
return {
padChest,
capL,
capR,
skateL,
skateR,
gloveL,
gloveR,
helmet,
/** Skinned cloth meshes — these go on the mover, not on a bone. */
skinned,
pieces,
attachTo(bones, mover) {
for (const m of skinned) mover.add(m);
bones.upperArmL.add(capL);
bones.upperArmR.add(capR);
bones.footL.add(skateL);
bones.footR.add(skateR);
bones.handL.add(gloveL);
bones.handR.add(gloveR);
bones.head.add(helmet);
},
destroy() {
for (const p of pieces) p.removeFromParent();
for (const g of disposables) g.dispose();
},
};
}
export function buildSkaterGearMaterials(teamJersey, teamAccent = 0xf0e6d2) {
return {
/** Cloth: jersey, socks. Vertex-coloured, matte. */
cloth: new THREE.MeshStandardMaterial({
color: 0xffffff,
vertexColors: true,
roughness: 0.88,
metalness: 0.0,
}),
/** Padded shells: pants, shoulder pads. */
padded: new THREE.MeshStandardMaterial({
color: 0xffffff,
vertexColors: true,
roughness: 0.72,
metalness: 0.02,
}),
/** Hard shells: helmet, skate boots, gloves. */
hard: new THREE.MeshStandardMaterial({
color: 0xffffff,
vertexColors: true,
roughness: 0.38,
metalness: 0.06,
}),
steel: new THREE.MeshStandardMaterial({
color: 0xc8ccd4,
roughness: 0.22,
metalness: 0.85,
}),
holder: new THREE.MeshStandardMaterial({
color: 0x16181d,
roughness: 0.45,
metalness: 0.1,
}),
lace: new THREE.MeshStandardMaterial({ color: 0xdad6cc, roughness: 0.9 }),
strap: new THREE.MeshStandardMaterial({ color: 0x14141a, roughness: 0.85 }),
visor: new THREE.MeshPhysicalMaterial({
color: 0x9fb8c8,
roughness: 0.08,
metalness: 0.0,
transparent: true,
opacity: 0.32,
side: THREE.DoubleSide,
}),
// Colour sources for the vertex-painted pieces.
jersey: new THREE.MeshStandardMaterial({ color: teamJersey }),
accent: new THREE.MeshStandardMaterial({ color: teamAccent }),
trim: new THREE.MeshStandardMaterial({ color: 0x16181d }),
pad: new THREE.MeshStandardMaterial({ color: 0x3a3f4a }),
tape: new THREE.MeshStandardMaterial({ color: 0xe8e4d8 }),
};
}