SOURCE / PINNED RELEASE
Made of little things.
Chase'n Those Narly Waves
- Release
- 0c7dde9d4921…
- Author-recorded commit
- 88e58516be17…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-836af5a08b9
Archive hash verified: e1acfdbe67c54dd4…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// 3D models built from simple shapes: the surfer, rocks and sharks.
// Sizes are in meters. The board points toward the horizon (-z).
import * as THREE from 'three';
/** Bright Hawaiian flower print for the board shorts, painted in code. */
function makeHawaiianTexture(): THREE.Texture {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 256;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#0b8f9c';
ctx.fillRect(0, 0, 256, 256);
let seed = 7;
const rand = (): number => ((seed = (seed * 16807) % 2147483647) / 2147483647);
// Leaves.
for (let i = 0; i < 22; i++) {
ctx.save();
ctx.translate(rand() * 256, rand() * 256);
ctx.rotate(rand() * Math.PI * 2);
ctx.fillStyle = rand() > 0.5 ? '#1f6b2e' : '#2f8f3a';
ctx.beginPath();
ctx.ellipse(0, 0, 26, 9, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// Hibiscus flowers.
for (let i = 0; i < 12; i++) {
const x = rand() * 256;
const y = rand() * 256;
const size = 12 + rand() * 10;
const color = ['#ff3b5c', '#ff8a1f', '#ffd23f', '#ff5fa8'][i % 4]!;
ctx.fillStyle = color;
for (let petal = 0; petal < 5; petal++) {
const angle = (petal / 5) * Math.PI * 2 + i;
ctx.beginPath();
ctx.arc(x + Math.cos(angle) * size * 0.6, y + Math.sin(angle) * size * 0.6, size * 0.55, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#fff3b0';
ctx.beginPath();
ctx.arc(x, y, size * 0.22, 0, Math.PI * 2);
ctx.fill();
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(2, 1);
return texture;
}
const materials = {
board: new THREE.MeshStandardMaterial({ color: 0xfff1d2, roughness: 0.25, metalness: 0 }),
boardStripe: new THREE.MeshStandardMaterial({ color: 0xe0452b, roughness: 0.35 }),
shorts: new THREE.MeshStandardMaterial({ map: makeHawaiianTexture(), roughness: 0.7 }),
skin: new THREE.MeshStandardMaterial({ color: 0xb4744c, roughness: 0.5 }), // sun-tanned
hair: new THREE.MeshStandardMaterial({ color: 0xf5c46a, roughness: 0.75 }), // sun-bleached blond
glassesFrame: new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.3 }),
glassesLens: new THREE.MeshStandardMaterial({ color: 0x2aa9ff, roughness: 0.05, metalness: 1 }), // mirrored
shell: new THREE.MeshStandardMaterial({ color: 0xf3eadb, roughness: 0.4 }),
rock: new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.92, metalness: 0 }),
shark: new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.55, metalness: 0.05 }),
sharkFin: new THREE.MeshStandardMaterial({ color: 0x3a434b, roughness: 0.55 }),
mouth: new THREE.MeshStandardMaterial({ color: 0x4a0d14, roughness: 0.8 }),
teeth: new THREE.MeshStandardMaterial({ color: 0xf2efe6, roughness: 0.4 }),
eye: new THREE.MeshStandardMaterial({ color: 0x050505, roughness: 0.2 }),
};
/** A rounded tube (arm, leg, body) from one point to another. */
function limb(from: THREE.Vector3, to: THREE.Vector3, radius: number, material: THREE.Material): THREE.Mesh {
const direction = new THREE.Vector3().subVectors(to, from);
const length = direction.length();
const mesh = new THREE.Mesh(new THREE.CapsuleGeometry(radius, Math.max(0.01, length - radius), 6, 12), material);
mesh.position.copy(from).addScaledVector(direction, 0.5);
mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.normalize());
return mesh;
}
function ball(at: THREE.Vector3, radius: number, material: THREE.Material): THREE.Mesh {
const mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, 20, 14), material);
mesh.position.copy(at);
return mesh;
}
/** A stretched ball (for hair tufts). */
function blob(at: THREE.Vector3, size: THREE.Vector3, material: THREE.Material, tilt = 0): THREE.Mesh {
const mesh = ball(at, 1, material);
mesh.scale.copy(size);
mesh.rotation.z = tilt;
return mesh;
}
const v = (x: number, y: number, z: number): THREE.Vector3 => new THREE.Vector3(x, y, z);
/** A point part of the way from a to b. */
const between = (a: THREE.Vector3, b: THREE.Vector3, t: number): THREE.Vector3 => a.clone().lerp(b, t);
/**
* The surfer bro's head, facing +x: long shaggy blond hair,
* mirrored shades. `center` is the middle of the head.
*/
function makeBroHead(center: THREE.Vector3): THREE.Group {
const head = new THREE.Group();
head.position.copy(center);
head.add(ball(v(0, 0, 0), 0.115, materials.skin));
head.add(ball(v(0.1, -0.02, 0), 0.03, materials.skin)); // nose
// Long, messy, sun-bleached hair: a cap on top, a big flow down the back, shaggy sides and bangs.
const hair = materials.hair;
head.add(blob(v(-0.01, 0.045, 0), v(0.125, 0.1, 0.13), hair));
head.add(blob(v(-0.09, -0.06, 0), v(0.07, 0.17, 0.12), hair, -0.2));
head.add(blob(v(-0.1, -0.17, 0), v(0.05, 0.1, 0.1), hair, -0.35));
for (const side of [-1, 1]) {
head.add(blob(v(-0.02, -0.06, side * 0.1), v(0.07, 0.13, 0.04), hair, -0.15));
head.add(blob(v(-0.05, -0.15, side * 0.08), v(0.05, 0.08, 0.04), hair, -0.3));
}
head.add(blob(v(0.08, 0.07, 0.03), v(0.06, 0.035, 0.08), hair, 0.5)); // bangs
head.add(blob(v(0.05, 0.11, -0.04), v(0.05, 0.03, 0.06), hair, 0.9)); // messy tuft
// Mirrored sunglasses.
const frame = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.025, 0.22), materials.glassesFrame);
frame.position.set(0.105, 0.035, 0);
head.add(frame);
for (const side of [-1, 1]) {
const lens = new THREE.Mesh(new THREE.BoxGeometry(0.015, 0.05, 0.085), materials.glassesLens);
lens.position.set(0.11, 0.02, side * 0.05);
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.012, 0.01), materials.glassesFrame);
arm.position.set(0.05, 0.035, side * 0.112);
head.add(lens, arm);
}
return head;
}
/** Shell necklace around the neck. */
function makeNecklace(at: THREE.Vector3): THREE.Group {
const necklace = new THREE.Group();
necklace.position.copy(at);
const cord = new THREE.Mesh(new THREE.TorusGeometry(0.085, 0.006, 6, 24), materials.shell);
cord.rotation.x = Math.PI / 2;
necklace.add(cord);
for (let i = 0; i < 10; i++) {
const angle = (i / 10) * Math.PI * 2;
necklace.add(ball(v(Math.cos(angle) * 0.085, -0.004, Math.sin(angle) * 0.085), 0.016, materials.shell));
}
necklace.rotation.z = -0.35; // hangs lower on the chest
return necklace;
}
/** A leg: Hawaiian shorts down to just above the knee, tanned skin below. */
function makeLeg(foot: THREE.Vector3, knee: THREE.Vector3, hip: THREE.Vector3): THREE.Object3D[] {
return [
limb(foot, knee, 0.062, materials.skin),
limb(knee, hip, 0.078, materials.skin),
limb(between(knee, hip, 0.25), hip, 0.105, materials.shorts),
ball(foot.clone().setY(0.07), 0.068, materials.skin),
];
}
export interface SurferModel {
/** Moves with the water and leans. */
root: THREE.Group;
/** Spins and flips during tricks (pivot is at the surfer's middle). */
air: THREE.Group;
/** Standing surfer (crouches for grabs). */
standing: THREE.Group;
/** Lying on the board, paddling. */
paddling: THREE.Group;
leftArm: THREE.Object3D;
rightArm: THREE.Object3D;
}
const PIVOT_HEIGHT = 0.8;
export function makeSurfer(): SurferModel {
const root = new THREE.Group();
const air = new THREE.Group();
air.position.y = PIVOT_HEIGHT;
root.add(air);
const body = new THREE.Group();
body.position.y = -PIVOT_HEIGHT;
air.add(body);
// Surfboard: a long, thin, pointy oval with a stripe and a fin.
const board = new THREE.Mesh(new THREE.SphereGeometry(1, 40, 16), materials.board);
board.scale.set(0.29, 0.045, 1.05);
board.position.y = 0.03;
const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.01, 1.7), materials.boardStripe);
stripe.position.set(0, 0.075, 0);
const fin = new THREE.Mesh(new THREE.BoxGeometry(0.015, 0.16, 0.14), materials.board);
fin.position.set(0, -0.06, 0.85);
body.add(board, stripe, fin);
// Standing surfer bro, sideways on the board in a low, wide stance.
const standing = new THREE.Group();
const frontHip = v(-0.04, 0.78, -0.12);
const backHip = v(-0.04, 0.78, 0.12);
const chest = v(0.14, 1.22, 0);
standing.add(
...makeLeg(v(0.02, 0.08, -0.42), v(0.26, 0.42, -0.32), frontHip),
...makeLeg(v(0.02, 0.08, 0.4), v(0.22, 0.4, 0.26), backHip),
limb(frontHip, backHip, 0.14, materials.shorts), // shorts around the hips
limb(v(-0.04, 0.84, 0), chest, 0.16, materials.skin), // no shirt, bro
limb(v(0.1, 1.2, -0.12), v(0.1, 1.2, 0.12), 0.11, materials.skin), // shoulders
limb(chest, v(0.2, 1.36, 0), 0.06, materials.skin), // neck
makeNecklace(v(0.17, 1.3, 0)),
makeBroHead(v(0.23, 1.47, -0.03)),
);
// Arms out wide for balance: front arm reaching forward, back arm out behind.
const leftArm = new THREE.Group();
leftArm.position.set(0.12, 1.3, -0.18);
leftArm.add(limb(v(0, 0, 0), v(0.35, -0.05, -0.5), 0.055, materials.skin));
leftArm.add(ball(v(0.37, -0.05, -0.53), 0.05, materials.skin));
const rightArm = new THREE.Group();
rightArm.position.set(0.12, 1.3, 0.18);
rightArm.add(limb(v(0, 0, 0), v(-0.4, -0.12, 0.42), 0.055, materials.skin));
rightArm.add(ball(v(-0.42, -0.13, 0.44), 0.05, materials.skin));
standing.add(leftArm, rightArm);
// Paddling: lying on the board, head toward the horizon.
const paddling = new THREE.Group();
paddling.add(
limb(v(0, 0.2, -0.2), v(0, 0.2, 0.4), 0.16, materials.skin),
limb(v(0, 0.19, 0.35), v(0, 0.18, 0.6), 0.15, materials.shorts),
limb(v(-0.08, 0.15, 0.55), v(-0.1, 0.13, 0.85), 0.085, materials.shorts),
limb(v(0.08, 0.15, 0.55), v(0.1, 0.13, 0.85), 0.085, materials.shorts),
limb(v(-0.1, 0.13, 0.8), v(-0.11, 0.12, 1.2), 0.065, materials.skin),
limb(v(0.1, 0.13, 0.8), v(0.11, 0.12, 1.2), 0.065, materials.skin),
);
// Same head, turned to look forward while lying down.
const paddleHead = makeBroHead(v(0, 0.32, -0.42));
paddleHead.rotation.y = Math.PI / 2;
paddling.add(paddleHead);
const paddleLeft = limb(v(-0.18, 0.25, -0.25), v(-0.3, -0.1, -0.7), 0.05, materials.skin);
const paddleRight = limb(v(0.18, 0.25, -0.25), v(0.3, -0.1, -0.7), 0.05, materials.skin);
paddleLeft.name = 'paddleLeft';
paddleRight.name = 'paddleRight';
paddling.add(paddleLeft, paddleRight);
body.add(standing, paddling);
return { root, air, standing, paddling, leftArm, rightArm };
}
// ---------- Rocks ----------
/** A lumpy, wet-looking rock. `seed` makes each one a different shape. */
export function makeRockGeometry(seed: number): THREE.BufferGeometry {
const geometry = new THREE.IcosahedronGeometry(1, 3);
const position = geometry.attributes.position as THREE.BufferAttribute;
const colors: number[] = [];
const p = new THREE.Vector3();
for (let i = 0; i < position.count; i++) {
p.fromBufferAttribute(position, i);
const lump =
1 +
0.22 * Math.sin(p.x * 3.1 + seed) * Math.cos(p.z * 2.7 + seed * 1.3) +
0.12 * Math.sin(p.y * 5.3 + seed * 2.1) +
0.06 * Math.sin((p.x + p.z) * 9.1 + seed);
p.multiplyScalar(lump);
p.y *= 0.75;
position.setXYZ(i, p.x, p.y, p.z);
// Darker and wetter near the water, lighter and dry on top.
const dry = THREE.MathUtils.clamp((p.y + 0.2) / 0.9, 0, 1);
const speck = 0.9 + 0.1 * Math.sin(p.x * 21 + p.z * 17 + seed);
colors.push((0.13 + dry * 0.17) * speck, (0.12 + dry * 0.15) * speck, (0.11 + dry * 0.12) * speck);
}
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
geometry.computeVertexNormals();
return geometry;
}
export function makeRock(geometry: THREE.BufferGeometry): THREE.Mesh {
return new THREE.Mesh(geometry, materials.rock);
}
// ---------- Sharks ----------
function finShape(points: [number, number][]): THREE.Shape {
const shape = new THREE.Shape();
points.forEach(([x, y], i) => (i === 0 ? shape.moveTo(x, y) : shape.lineTo(x, y)));
return shape;
}
/** The curved fin that sticks out of the water. Points along -z (forward). */
function makeDorsalFin(scale: number): THREE.Mesh {
const shape = new THREE.Shape();
shape.moveTo(0.38, 0);
shape.quadraticCurveTo(0.2, 0.45, -0.22, 0.62); // front edge sweeps back to the tip
shape.quadraticCurveTo(-0.12, 0.3, -0.35, 0); // curved back edge
shape.lineTo(0.38, 0);
const geometry = new THREE.ExtrudeGeometry(shape, { depth: 0.04, bevelEnabled: true, bevelSize: 0.02, bevelThickness: 0.02, bevelSegments: 2 });
geometry.translate(0, 0, -0.02);
geometry.rotateY(Math.PI / 2); // shape's x becomes -z... front edge points forward
geometry.scale(scale, scale, scale);
return new THREE.Mesh(geometry, materials.sharkFin);
}
/** Just the fin and a bit of back, for a shark swimming at the surface. */
export function makeSharkFin(): THREE.Group {
const group = new THREE.Group();
const fin = makeDorsalFin(1.25);
const back = new THREE.Mesh(new THREE.SphereGeometry(1, 20, 10), materials.sharkFin);
back.scale.set(0.3, 0.12, 1.1);
back.position.y = -0.04;
group.add(fin, back);
return group;
}
/** A whole great white shark, jaws open, for the big leap. Nose points to -z. */
export function makeLeapingShark(): THREE.Group {
const group = new THREE.Group();
// Body: a torpedo shape spun around its middle.
const profile: [number, number][] = [
[0.0, -1.3],
[0.07, -1.2],
[0.2, -0.8],
[0.34, -0.15],
[0.34, 0.35],
[0.26, 0.85],
[0.14, 1.15],
[0.0, 1.28],
];
const body = new THREE.LatheGeometry(
profile.map(([r, y]) => new THREE.Vector2(r, y)),
28,
);
body.rotateX(-Math.PI / 2); // nose (+y) now points to -z
// Grey on top, white belly.
const position = body.attributes.position as THREE.BufferAttribute;
const colors: number[] = [];
for (let i = 0; i < position.count; i++) {
const y = position.getY(i);
const belly = THREE.MathUtils.smoothstep(-y, -0.02, 0.1);
const grey = [0.16, 0.19, 0.22];
const white = [0.88, 0.88, 0.86];
colors.push(...grey.map((g, j) => g + (white[j]! - g) * belly));
}
body.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
body.computeVertexNormals();
group.add(new THREE.Mesh(body, materials.shark));
const dorsal = makeDorsalFin(1);
dorsal.position.set(0, 0.28, 0.05);
group.add(dorsal);
// Tail fin (up and down lobes).
const tail = new THREE.ExtrudeGeometry(finShape([[0, 0], [0.55, 0.62], [0.42, 0.05], [0.4, -0.05], [0.5, -0.42], [0, 0]]), {
depth: 0.04,
bevelEnabled: false,
});
tail.translate(0, 0, -0.02);
tail.rotateY(-Math.PI / 2);
const tailMesh = new THREE.Mesh(tail, materials.sharkFin);
tailMesh.position.set(0, 0, 1.18);
group.add(tailMesh);
// Side fins.
for (const side of [-1, 1]) {
const pectoral = new THREE.ExtrudeGeometry(finShape([[0, 0], [0.55, -0.12], [0.62, -0.2], [0.1, -0.05], [0, 0]]), {
depth: 0.03,
bevelEnabled: false,
});
const mesh = new THREE.Mesh(pectoral, materials.sharkFin);
mesh.rotation.set(-Math.PI / 2, 0, side > 0 ? -0.5 : Math.PI + 0.5);
mesh.position.set(side * 0.28, -0.15, -0.35);
group.add(mesh);
}
// Open mouth with teeth.
const mouth = new THREE.Mesh(new THREE.SphereGeometry(0.24, 18, 10, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2), materials.mouth);
mouth.scale.set(1, 0.7, 1.1);
mouth.position.set(0, -0.07, -0.88);
group.add(mouth);
for (let i = 0; i < 9; i++) {
const angle = (i / 8 - 0.5) * Math.PI * 0.9;
const tooth = new THREE.Mesh(new THREE.ConeGeometry(0.025, 0.07, 6), materials.teeth);
tooth.position.set(Math.sin(angle) * 0.2, -0.1, -0.88 - Math.cos(angle) * 0.22);
tooth.rotation.x = Math.PI;
group.add(tooth);
}
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.035, 10, 8), materials.eye);
eye.position.set(side * 0.19, 0.06, -0.95);
group.add(eye);
}
return group;
}
