Initial commit

This commit is contained in:
ryanfitzpatrickio
2026-08-03 06:43:21 -05:00
commit 7ee3e9d02f
63 changed files with 15792 additions and 0 deletions
+693
View File
@@ -0,0 +1,693 @@
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 }),
};
}