694 lines
26 KiB
JavaScript
694 lines
26 KiB
JavaScript
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 }),
|
||
};
|
||
}
|