Skip to content

Instantly share code, notes, and snippets.

@oaustegard
Created September 7, 2026 02:49
Show Gist options
  • Select an option

  • Save oaustegard/482be2a1181a506b2050bbd12e396d6d to your computer and use it in GitHub Desktop.

Select an option

Save oaustegard/482be2a1181a506b2050bbd12e396d6d to your computer and use it in GitHub Desktop.
PelicanThree
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pelican, bicycle</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #a9d6f0; }
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
#caption {
position: fixed; left: 18px; bottom: 14px;
font: italic 15px/1.3 "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
color: #23384a; opacity: 0.85; pointer-events: none; user-select: none;
}
#caption span { display: block; font-style: normal; font-size: 12px; opacity: 0.75; margin-top: 2px; }
</style>
</head>
<body>
<canvas id="view"></canvas>
<div id="caption">Pelican, bicycle.<span>drag to look around · scroll to zoom</span></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function () {
const V = (x, y, z) => new THREE.Vector3(x, y, z);
const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ---------- renderer / scene / camera ----------
const canvas = document.getElementById('view');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(26, 1, 0.1, 120);
const target = V(0.05, 0.92, 0);
const orbit = { az: 0.2, el: 0.11, dist: 5.8, idle: 0 };
function placeCamera() {
camera.position.set(
target.x + orbit.dist * Math.cos(orbit.el) * Math.sin(orbit.az),
target.y + orbit.dist * Math.sin(orbit.el),
target.z + orbit.dist * Math.cos(orbit.el) * Math.cos(orbit.az));
camera.lookAt(target);
}
// ---------- materials ----------
const M = {
feather: new THREE.MeshStandardMaterial({ color: 0xf7f4ea, roughness: 0.75, metalness: 0 }),
featherDark: new THREE.MeshStandardMaterial({ color: 0x3a3a3a, roughness: 0.8 }),
beak: new THREE.MeshStandardMaterial({ color: 0xf1b43c, roughness: 0.45 }),
pouch: new THREE.MeshStandardMaterial({ color: 0xe98a45, roughness: 0.6 }),
leg: new THREE.MeshStandardMaterial({ color: 0xe0812a, roughness: 0.7 }),
eyeWhite: new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.3 }),
eye: new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.2 }),
frame: new THREE.MeshStandardMaterial({ color: 0x2a9d8f, roughness: 0.45, metalness: 0.15 }),
fender: new THREE.MeshStandardMaterial({ color: 0xf2d9a0, roughness: 0.6 }),
basket: new THREE.MeshStandardMaterial({ color: 0xc9a06a, roughness: 0.9 }),
hat: new THREE.MeshStandardMaterial({ color: 0xf1e3b8, roughness: 0.85 }),
ribbon: new THREE.MeshStandardMaterial({ color: 0xe98a7a, roughness: 0.7 }),
scarf: new THREE.MeshStandardMaterial({ color: 0xe98a7a, roughness: 0.75 }),
sand: new THREE.MeshStandardMaterial({ color: 0xf1cfb2, roughness: 1 }),
sea: new THREE.MeshStandardMaterial({ color: 0x2b98a1, roughness: 0.7 }),
foam: new THREE.MeshStandardMaterial({ color: 0xfaf3ea, roughness: 1 }),
palm: new THREE.MeshStandardMaterial({ color: 0xc98b5a, roughness: 0.9 }),
frond: new THREE.MeshStandardMaterial({ color: 0x3f8f4c, roughness: 0.9, side: THREE.DoubleSide }),
roof: new THREE.MeshStandardMaterial({ color: 0x4f6f56, roughness: 0.9 }),
hutTeal: new THREE.MeshStandardMaterial({ color: 0x2a9d8f, roughness: 0.9 }),
hutCoral: new THREE.MeshStandardMaterial({ color: 0xe8846f, roughness: 0.9 }),
cream: new THREE.MeshStandardMaterial({ color: 0xf7ead2, roughness: 0.9 }),
balloonTeal: new THREE.MeshStandardMaterial({ color: 0x2a9d8f, roughness: 0.25, metalness: 0.05 }),
balloonCoral: new THREE.MeshStandardMaterial({ color: 0xe88a8a, roughness: 0.25, metalness: 0.05 }),
balloonCream: new THREE.MeshStandardMaterial({ color: 0xf3e4b5, roughness: 0.25, metalness: 0.05 }),
string: new THREE.MeshStandardMaterial({ color: 0xfaf3ea, roughness: 1 }),
petalPink: new THREE.MeshStandardMaterial({ color: 0xf2a0a0, roughness: 0.8 }),
petalWhite: new THREE.MeshStandardMaterial({ color: 0xfbf7ef, roughness: 0.8 }),
stamen: new THREE.MeshStandardMaterial({ color: 0xf6c95a, roughness: 0.8 }),
stem: new THREE.MeshStandardMaterial({ color: 0x5b9a4e, roughness: 0.9 }),
sail: new THREE.MeshStandardMaterial({ color: 0xfbf7ef, roughness: 0.9, side: THREE.DoubleSide }),
sailCoral: new THREE.MeshStandardMaterial({ color: 0xe8846f, roughness: 0.9, side: THREE.DoubleSide }),
chrome: new THREE.MeshStandardMaterial({ color: 0xd9dde1, roughness: 0.25, metalness: 0.85 }),
dark: new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.6, metalness: 0.4 }),
tire: new THREE.MeshStandardMaterial({ color: 0x232323, roughness: 0.95 }),
saddle: new THREE.MeshStandardMaterial({ color: 0x6b4a34, roughness: 0.8 }),
grass: new THREE.MeshStandardMaterial({ color: 0x6fae5c, roughness: 1 }),
road: new THREE.MeshStandardMaterial({ color: 0x4b4b4b, roughness: 1 }),
paint: new THREE.MeshStandardMaterial({ color: 0xf3e6a3, roughness: 0.9 }),
hill: new THREE.MeshStandardMaterial({ color: 0x8fc47c, roughness: 1 }),
trunk: new THREE.MeshStandardMaterial({ color: 0x7b5a3a, roughness: 1 }),
leaf: new THREE.MeshStandardMaterial({ color: 0x4f9a48, roughness: 1 }),
cloud: new THREE.MeshStandardMaterial({ color: 0xfbeee3, roughness: 1 }),
};
for (const k in M) M[k].color.convertSRGBToLinear();
// ---------- helpers ----------
const Y = V(0, 1, 0);
function tube(a, b, r, mat, parent) {
const d = b.clone().sub(a), len = d.length();
const m = new THREE.Mesh(new THREE.CylinderGeometry(r, r, len, 14), mat);
m.position.copy(a).add(b).multiplyScalar(0.5);
m.quaternion.setFromUnitVectors(Y, d.normalize());
m.castShadow = true; m.receiveShadow = true;
(parent || scene).add(m);
return m;
}
function sphere(pos, r, mat, scale, parent) {
const m = new THREE.Mesh(new THREE.SphereGeometry(r, 28, 20), mat);
m.position.copy(pos);
if (scale) m.scale.set(scale[0], scale[1], scale[2]);
m.castShadow = true; m.receiveShadow = true;
(parent || scene).add(m);
return m;
}
function box(pos, size, mat, parent) {
const m = new THREE.Mesh(new THREE.BoxGeometry(size[0], size[1], size[2]), mat);
m.position.copy(pos);
m.castShadow = true; m.receiveShadow = true;
(parent || scene).add(m);
return m;
}
function torus(pos, R, r, mat, arc, parent) {
const m = new THREE.Mesh(new THREE.TorusGeometry(R, r, 12, 48, arc || Math.PI * 2), mat);
m.position.copy(pos);
m.castShadow = true; m.receiveShadow = true;
(parent || scene).add(m);
return m;
}
// ---------- lights & sky ----------
scene.add(new THREE.HemisphereLight(0xfff0e6, 0xe8c4a8, 0.95));
scene.add(new THREE.AmbientLight(0xffdcc8, 0.22));
const sun = new THREE.DirectionalLight(0xffe8d8, 0.8);
sun.position.set(-3, 6, 4.5);
sun.shadow.radius = 5;
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -3; sun.shadow.camera.right = 3;
sun.shadow.camera.top = 3; sun.shadow.camera.bottom = -3;
sun.shadow.camera.near = 1; sun.shadow.camera.far = 20;
sun.shadow.bias = -0.0015;
scene.add(sun);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false, fog: false,
uniforms: { top: { value: new THREE.Color(0xe9b7a8) }, bottom: { value: new THREE.Color(0xf7dcc8) } },
vertexShader: 'varying float h; void main(){ h = normalize(position).y; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }',
fragmentShader: 'uniform vec3 top; uniform vec3 bottom; varying float h; void main(){ float t = smoothstep(0.02, 0.6, h); gl_FragColor = vec4(mix(bottom, top, t), 1.0); }',
});
scene.add(new THREE.Mesh(new THREE.SphereGeometry(48, 32, 16), skyMat));
// ---------- beach ----------
const ground = new THREE.Mesh(new THREE.PlaneGeometry(140, 140), M.sand);
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground);
const sea = new THREE.Mesh(new THREE.PlaneGeometry(140, 40), M.sea);
sea.rotation.x = -Math.PI / 2; sea.position.set(0, 0.012, -26); scene.add(sea);
const shoreline = new THREE.Mesh(new THREE.PlaneGeometry(140, 0.5), M.foam);
shoreline.rotation.x = -Math.PI / 2; shoreline.position.set(0, 0.014, -6.1); scene.add(shoreline);
const sunDisc = new THREE.Mesh(new THREE.CircleGeometry(5.5, 48), new THREE.MeshBasicMaterial({ color: 0xf3c7a6 }));
sunDisc.position.set(6.5, 6.8, -44); sunDisc.lookAt(0, 3, 0); scene.add(sunDisc);
const scrolling = []; // [object, speed factor] — the beach streams past
const ROAD_LEN = 48;
let seed = 11;
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
function stream(obj, f) { scrolling.push([obj, f]); }
// foam lines drawn across the water
for (let i = 0; i < 7; i++) {
const z0 = -7 - i * 2.4, pts = [];
for (let x = -ROAD_LEN / 2; x <= ROAD_LEN / 2; x += 1.2) pts.push(V(x, 0.02, z0 + 0.5 * Math.sin(x * 0.5 + i)));
const line = new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 140, 0.07, 6, false), M.foam);
line.position.x = (rnd() - 0.5) * 4; scene.add(line); stream(line, 0.3);
}
// confetti on the sand
const confettiCols = [0xf2a7a7, 0xa9dcc9, 0xc9b8e6, 0xf6c95a, 0x2a9d8f, 0xe8846f].map(c => new THREE.MeshStandardMaterial({ color: c, roughness: 1 }));
confettiCols.forEach(m => m.color.convertSRGBToLinear());
for (let i = 0; i < 90; i++) {
const c = box(V(-ROAD_LEN / 2 + rnd() * ROAD_LEN, 0.004, -1.6 + rnd() * 4.5), [0.06, 0.005, 0.035], confettiCols[i % confettiCols.length]);
c.rotation.y = rnd() * Math.PI; c.castShadow = false; stream(c, 1);
}
// palms
function frondShape() {
const sh = new THREE.Shape();
sh.moveTo(0, 0);
for (let i = 1; i <= 9; i++) { const x = i / 9; sh.lineTo(x * 1.25, 0.15 * Math.sin(x * Math.PI) + (i % 2 ? 0.05 : 0)); }
sh.lineTo(1.25, 0);
for (let i = 9; i >= 1; i--) { const x = i / 9; sh.lineTo(x * 1.25, -0.15 * Math.sin(x * Math.PI) - (i % 2 ? 0.05 : 0)); }
sh.closePath(); return sh;
}
const frondGeo = new THREE.ExtrudeGeometry(frondShape(), { depth: 0.015, bevelEnabled: false });
function palm(x, z, h, lean) {
const g = new THREE.Group();
const trunk = new THREE.QuadraticBezierCurve3(V(0, 0, 0), V(lean * 0.4, h * 0.55, 0), V(lean, h, 0));
const t = new THREE.Mesh(new THREE.TubeGeometry(trunk, 16, 0.075, 10, false), M.palm);
t.castShadow = true; g.add(t);
const top = V(lean, h, 0);
for (let i = 0; i < 9; i++) {
const f = new THREE.Mesh(frondGeo, M.frond);
f.position.copy(top); f.rotation.y = (i / 9) * Math.PI * 2; f.rotateZ(-0.55 - 0.4 * (i % 3) / 2);
f.rotateX(Math.PI / 2 * (i % 2 ? 1 : -1) * 0.5); f.castShadow = true; g.add(f);
}
sphere(top, 0.13, M.palm, null, g);
g.position.set(x, 0, z); scene.add(g); stream(g, 1);
}
palm(-3.3, -3.5, 2.6, 0.4); palm(12, -4.2, 2.5, -0.35); palm(21, -3.4, 2.8, 0.3);
// beach huts
function hut(x, z, mat) {
const g = new THREE.Group();
const w = 0.95, h = 0.85, d = 0.8;
box(V(0, h / 2, 0), [w, h, d], mat, g);
const roofShape = new THREE.Shape(); roofShape.moveTo(-w / 2 - 0.08, 0); roofShape.lineTo(0, 0.45); roofShape.lineTo(w / 2 + 0.08, 0); roofShape.closePath();
const roof = new THREE.Mesh(new THREE.ExtrudeGeometry(roofShape, { depth: d + 0.16, bevelEnabled: false }), M.roof);
roof.position.set(0, h, -d / 2 - 0.08); roof.castShadow = true; g.add(roof);
for (let i = 0; i < 5; i++) box(V(-w / 2 + 0.12 + i * 0.18, h / 2, d / 2 + 0.004), [0.05, h - 0.06, 0.008], M.cream, g);
box(V(0.02, 0.26, d / 2 + 0.012), [0.24, 0.5, 0.012], M.cream, g);
box(V(0.02, 0.26, d / 2 + 0.02), [0.18, 0.44, 0.01], mat, g);
box(V(0, 0.02, 0), [w + 0.3, 0.04, d + 0.3], M.cream, g);
g.position.set(x, 0, z); scene.add(g); stream(g, 1);
}
hut(-2.9, -4.8, M.hutTeal); hut(-1.8, -4.6, M.hutCoral); hut(15, -4.7, M.hutCoral); hut(16.1, -4.5, M.hutTeal);
// bunting, hung high across the scene
const flagCols = [0xf2a7a7, 0xf7e3b0, 0xa9dcc9, 0xc9b8e6, 0xcfe3a6, 0xf5c69a, 0x8ecdd0, 0x2a9d8f]
.map(c => { const m = new THREE.MeshStandardMaterial({ color: c, roughness: 1, side: THREE.DoubleSide }); m.color.convertSRGBToLinear(); return m; });
const flagShape = new THREE.Shape(); flagShape.moveTo(-0.16, 0); flagShape.lineTo(0.16, 0); flagShape.lineTo(0, -0.32); flagShape.closePath();
const flagGeo = new THREE.ExtrudeGeometry(flagShape, { depth: 0.01, bevelEnabled: false });
function bunting(a, b, sag, n) {
const curve = new THREE.QuadraticBezierCurve3(a, a.clone().add(b).multiplyScalar(0.5).add(V(0, -sag, 0)), b);
scene.add(new THREE.Mesh(new THREE.TubeGeometry(curve, 40, 0.01, 6, false), M.string));
for (let i = 0; i < n; i++) {
const f = new THREE.Mesh(flagGeo, flagCols[i % flagCols.length]);
f.position.copy(curve.getPoint((i + 0.5) / n)); f.rotation.z = -curve.getTangent((i + 0.5) / n).y * 0.8; scene.add(f);
}
}
bunting(V(-7, 3.6, -7), V(3.5, 3.1, -7.2), 0.7, 16);
bunting(V(2.5, 3.2, -7.1), V(14, 3.8, -7), 0.7, 16);
// sailboat out on the water
const boat = new THREE.Group();
sphere(V(0, 0.02, 0), 0.5, M.hutCoral, [1.1, 0.28, 0.4], boat);
tube(V(0, 0, 0), V(0, 1.35, 0), 0.02, M.palm, boat);
const mainSail = new THREE.Shape(); mainSail.moveTo(0.03, 0.15); mainSail.lineTo(0.03, 1.3); mainSail.lineTo(-0.7, 0.15); mainSail.closePath();
const jib = new THREE.Shape(); jib.moveTo(-0.03, 0.15); jib.lineTo(-0.03, 1.1); jib.lineTo(0.55, 0.15); jib.closePath();
boat.add(new THREE.Mesh(new THREE.ExtrudeGeometry(mainSail, { depth: 0.01, bevelEnabled: false }), M.sailCoral));
boat.add(new THREE.Mesh(new THREE.ExtrudeGeometry(jib, { depth: 0.01, bevelEnabled: false }), M.sail));
boat.position.set(7, 0.02, -12); scene.add(boat); stream(boat, 0.12);
// a couple of flat clouds
function cloud(x, y, z, s) {
const g = new THREE.Group();
[[0, 0, 1], [0.9, 0.2, 1.25], [1.9, 0, 0.9], [1, -0.2, 0.8]].forEach(p => {
const m = sphere(V(p[0] * s, p[1] * s, 0), 0.55 * s * p[2], M.cloud, [1, 0.55, 1], g); m.castShadow = false;
});
g.position.set(x, y, z); scene.add(g);
}
cloud(-4, 6.2, -20, 1.4); cloud(21, 7.6, -24, 1.7);
// ---------- bicycle ----------
const R_WHEEL = 0.34;
const RH = V(-0.50, R_WHEEL, 0), FH = V(0.55, R_WHEEL, 0);
const BB = V(-0.05, 0.28, 0), SC = V(-0.22, 0.74, 0);
const U = V(-0.326, 0.946, 0); // steering axis, 71° head angle
const HB = V(0.43, 0.55, 0), HT = HB.clone().add(U.clone().multiplyScalar(0.15));
const CROWN = HB.clone().sub(U.clone().multiplyScalar(0.03));
const STEM_TOP = HT.clone().add(U.clone().multiplyScalar(0.07));
const SP = V(-0.262, 0.853, 0), BAR = V(0.41, 0.80, 0);
const bike = new THREE.Group(); scene.add(bike);
for (const z of [-0.045, 0.045]) {
tube(V(RH.x, RH.y, z), V(BB.x, BB.y, z), 0.011, M.frame, bike); // chainstays
tube(V(RH.x, RH.y, z), V(SC.x, SC.y, z), 0.010, M.frame, bike); // seat stays
}
for (const z of [-0.05, 0.05]) { // raked fork blades off the crown
const blade = new THREE.QuadraticBezierCurve3(
V(CROWN.x, CROWN.y, z),
V(CROWN.x - U.x * 0.16, CROWN.y - U.y * 0.16, z),
V(FH.x, FH.y, z));
const bm = new THREE.Mesh(new THREE.TubeGeometry(blade, 12, 0.011, 10, false), M.frame);
bm.castShadow = true; bike.add(bm);
}
const crown = box(CROWN, [0.034, 0.03, 0.125], M.frame, bike);
crown.quaternion.setFromUnitVectors(Y, U);
tube(BB, SC, 0.016, M.frame, bike); // seat tube
tube(SC, HT, 0.016, M.frame, bike); // top tube
tube(BB, HB.clone().add(U.clone().multiplyScalar(0.02)), 0.018, M.frame, bike); // down tube
tube(HB.clone().sub(U.clone().multiplyScalar(0.01)), HT.clone().add(U.clone().multiplyScalar(0.02)), 0.022, M.frame, bike); // head tube
tube(SC, SP, 0.011, M.chrome, bike); // seat post
tube(HT, STEM_TOP, 0.013, M.chrome, bike); // steerer
tube(STEM_TOP, BAR, 0.012, M.chrome, bike); // stem
tube(V(BAR.x, BAR.y, -0.23), V(BAR.x, BAR.y, 0.23), 0.011, M.chrome, bike); // handlebar
for (const z of [-0.23, 0.23]) { // grips, swept back
tube(V(BAR.x, BAR.y, z), V(BAR.x - 0.10, BAR.y - 0.01, z * 1.04), 0.011, M.chrome, bike);
tube(V(BAR.x - 0.04, BAR.y, z * 1.015), V(BAR.x - 0.12, BAR.y - 0.012, z * 1.045), 0.017, M.dark, bike);
}
sphere(BB, 0.03, M.dark, [1, 1, 1.4], bike); // bottom bracket shell
const saddle = sphere(V(SP.x - 0.02, SP.y + 0.025, 0), 0.12, M.saddle, [1.15, 0.28, 0.55], bike);
saddle.rotation.z = 0.08;
function wheel(pos) {
const g = new THREE.Group(); g.position.copy(pos); bike.add(g);
torus(V(0, 0, 0), R_WHEEL - 0.03, 0.032, M.tire, null, g);
torus(V(0, 0, 0), R_WHEEL - 0.06, 0.009, M.chrome, null, g);
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.022, 0.022, 0.08, 16), M.chrome);
hub.rotation.x = Math.PI / 2; hub.castShadow = true; g.add(hub);
for (let i = 0; i < 24; i++) {
const a = (i / 24) * Math.PI * 2, s = i % 2 ? 0.03 : -0.03;
tube(V(0.02 * Math.cos(a + 0.5), 0.02 * Math.sin(a + 0.5), s),
V((R_WHEEL - 0.065) * Math.cos(a), (R_WHEEL - 0.065) * Math.sin(a), 0), 0.002, M.chrome, g);
}
return g;
}
const rearWheel = wheel(RH), frontWheel = wheel(FH);
function fender(hub, start, arc) {
const f = torus(hub, R_WHEEL + 0.012, 0.012, M.fender, arc, bike);
f.rotation.z = start; f.scale.z = 4.2;
return f;
}
fender(RH, 0.25, 3.3); fender(FH, -0.2, 2.75);
sphere(V(RH.x - R_WHEEL - 0.02, RH.y + 0.12, 0), 0.02, M.ribbon, null, bike); // rear reflector
// wicker basket on the bars, with a few flowers in it
const basket = new THREE.Group(); basket.position.set(BAR.x + 0.16, BAR.y + 0.03, 0); bike.add(basket);
const bc = new THREE.Mesh(new THREE.CylinderGeometry(0.11, 0.09, 0.012, 24), M.basket); bc.position.y = -0.08; bc.castShadow = true; basket.add(bc);
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
tube(V(0.09 * Math.cos(a), -0.08, 0.09 * Math.sin(a)), V(0.115 * Math.cos(a), 0.08, 0.115 * Math.sin(a)), 0.005, M.basket, basket);
}
for (let k = 0; k < 4; k++) {
const r = 0.09 + 0.025 * k / 3, ring = torus(V(0, -0.075 + 0.05 * k, 0), r, 0.006, M.basket, null, basket); ring.rotation.x = Math.PI / 2;
}
tube(V(-0.16, -0.02, 0), V(-0.06, -0.06, 0), 0.007, M.chrome, basket); // bracket to the stem
function flower(x, z, h, petal) {
tube(V(x, -0.06, z), V(x, h, z), 0.006, M.stem, basket);
sphere(V(x, h, z), 0.018, M.stamen, null, basket);
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
sphere(V(x + 0.03 * Math.cos(a), h, z + 0.03 * Math.sin(a)), 0.02, petal, [1, 0.45, 1], basket);
}
}
flower(0.02, 0.02, 0.19, M.petalPink); flower(-0.04, -0.03, 0.15, M.petalWhite); flower(0.04, -0.04, 0.14, M.petalPink);
const bell = sphere(V(BAR.x - 0.02, BAR.y + 0.03, 0.09), 0.024, M.chrome, [1, 0.8, 1], bike);
const cog = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.008, 24), M.dark);
cog.rotation.x = Math.PI / 2; cog.position.set(RH.x, RH.y, 0.07); bike.add(cog);
// crank: chainring + arms; pedals stay level via counter-rotation
const crank = new THREE.Group(); crank.position.copy(BB); bike.add(crank);
const ring = new THREE.Mesh(new THREE.CylinderGeometry(0.105, 0.105, 0.006, 40), M.dark);
ring.rotation.x = Math.PI / 2; ring.position.z = 0.07; ring.castShadow = true; crank.add(ring);
const ringHole = new THREE.Mesh(new THREE.CylinderGeometry(0.075, 0.075, 0.008, 40), M.chrome);
ringHole.rotation.x = Math.PI / 2; ringHole.position.z = 0.07; crank.add(ringHole);
const pedals = [];
for (const side of [1, -1]) {
const arm = new THREE.Group(); arm.rotation.z = side > 0 ? 0 : Math.PI; crank.add(arm);
box(V(0.085, 0, side * 0.09), [0.17, 0.02, 0.014], M.chrome, arm);
const axle = tube(V(0.16, 0, side * 0.09), V(0.16, 0, side * 0.145), 0.006, M.chrome, arm);
const pedal = new THREE.Group(); pedal.position.set(0.16, 0, side * 0.135); arm.add(pedal);
box(V(0, 0, 0), [0.09, 0.018, 0.06], M.dark, pedal);
pedals.push({ side, arm, pedal });
}
// chain: two runs plus wraps around cog and chainring
const CZ = 0.07, RC = 0.05, RR = 0.105;
tube(V(RH.x, RH.y + RC, CZ), V(BB.x, BB.y + RR, CZ), 0.005, M.dark, bike); // top run
tube(V(RH.x, RH.y - RC, CZ), V(BB.x, BB.y - RR, CZ), 0.005, M.dark, bike); // bottom run
torus(V(RH.x, RH.y, CZ), RC, 0.005, M.dark, Math.PI, bike).rotation.z = Math.PI / 2;
torus(V(BB.x, BB.y, CZ), RR, 0.005, M.dark, Math.PI, bike).rotation.z = -Math.PI / 2;
// ---------- pelican ----------
const pelican = new THREE.Group(); scene.add(pelican);
const bodyC = V(-0.17, 1.03, 0);
const body = sphere(bodyC, 0.31, M.feather, [1.18, 0.70, 0.80], pelican);
body.rotation.z = -0.24;
// tail: a flattened wedge rooted inside the body, with three feather ridges on top
const tailBase = V(-0.40, 1.09, 0), tailTip = V(-0.80, 1.25, 0);
const tailDir = tailTip.clone().sub(tailBase), tailLen = tailDir.length();
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.10, tailLen, 8), M.feather);
tail.position.copy(tailBase).add(tailTip).multiplyScalar(0.5);
tail.quaternion.setFromUnitVectors(Y, tailDir.clone().normalize());
tail.scale.set(1, 1, 0.45); tail.castShadow = true; tail.receiveShadow = true; pelican.add(tail);
for (let i = -1; i <= 1; i++) {
const f = box(tailBase.clone().add(tailDir.clone().multiplyScalar(0.55)), [tailLen * 0.85, 0.012, 0.05], M.feather, pelican);
f.quaternion.setFromUnitVectors(V(1, 0, 0), tailDir.clone().normalize());
f.rotateY(i * 0.28); f.position.z = i * 0.03; f.position.y += 0.012;
}
// wings: extruded 2D profile, shoulder at origin, tip along +x, dark primaries at the tip
function wingShape(dark) {
const s = new THREE.Shape();
if (!dark) {
s.moveTo(0, 0.22);
s.quadraticCurveTo(0.48, 0.26, 1.0, 0.02);
s.lineTo(0.90, -0.12); s.lineTo(0.94, -0.03); s.lineTo(0.80, -0.16);
s.lineTo(0.86, -0.06); s.lineTo(0.70, -0.17);
s.quadraticCurveTo(0.42, -0.19, 0.06, -0.14);
s.quadraticCurveTo(-0.06, -0.05, 0, 0.22);
} else {
s.moveTo(0.66, 0.11);
s.lineTo(1.0, 0.02);
s.lineTo(0.90, -0.12); s.lineTo(0.94, -0.03); s.lineTo(0.80, -0.16);
s.lineTo(0.86, -0.06); s.lineTo(0.70, -0.17);
s.lineTo(0.62, -0.05);
}
return s;
}
function wing(side) {
const shoulder = V(-0.14, 1.16, side * 0.16), grip = V(BAR.x - 0.08, BAR.y + 0.04, side * 0.235);
const d = grip.clone().sub(shoulder), len = d.length();
// upper wing: an ellipsoid from shoulder to wrist, lying along the flank
const mid = shoulder.clone().add(grip).multiplyScalar(0.5);
const arm = sphere(mid, 1, M.feather, [len * 0.55, 0.11, 0.055], pelican);
arm.quaternion.setFromUnitVectors(V(1, 0, 0), d.clone().normalize());
arm.rotateX(side * 0.35); // tilt the flat face to follow the body's curve
// primaries: flat feathered profile from mid-wing to the grip
const g = new THREE.Group(); g.position.copy(shoulder); pelican.add(g);
g.quaternion.setFromUnitVectors(V(1, 0, 0), d.clone().normalize());
g.rotateX(side * 0.35);
const feathers = new THREE.Mesh(new THREE.ExtrudeGeometry(wingShape(false), { depth: 0.03, bevelEnabled: true, bevelThickness: 0.008, bevelSize: 0.008, bevelSegments: 2 }), M.feather);
feathers.scale.set(len, len * 0.75, 1); feathers.position.z = -0.015;
feathers.castShadow = true; feathers.receiveShadow = true; g.add(feathers);
const tips = new THREE.Mesh(new THREE.ExtrudeGeometry(wingShape(true), { depth: 0.032, bevelEnabled: false }), M.featherDark);
tips.scale.set(len, len * 0.75, 1); tips.position.z = -0.016; g.add(tips);
return g;
}
wing(1); wing(-1);
// covert rows: three stepped feather lobes over each wing, like the reference's stacked ridges
for (const side of [1, -1]) {
const shoulder = V(-0.14, 1.16, side * 0.16), grip = V(BAR.x - 0.08, BAR.y + 0.04, side * 0.235);
const d = grip.clone().sub(shoulder), n = d.clone().normalize();
for (let k = 0; k < 3; k++) {
const c = shoulder.clone().add(d.clone().multiplyScalar(0.22 + 0.2 * k)).add(V(0, 0.015, side * 0.03));
const lobe = sphere(c, 1, M.feather, [0.11 - 0.012 * k, 0.075 - 0.012 * k, 0.035], pelican);
lobe.quaternion.setFromUnitVectors(V(1, 0, 0), n); lobe.rotateX(side * 0.35);
}
}
// balloons tied to the tail
const balloons = new THREE.Group(); pelican.add(balloons);
const tailTie = V(-0.78, 1.24, 0);
[[-0.36, 0.42, 0.16, M.balloonTeal], [-0.20, 0.80, -0.12, M.balloonCoral], [-0.52, 0.76, 0.30, M.balloonCream]].forEach(([dx, dy, dz, mat], i) => {
const c = tailTie.clone().add(V(dx, dy, dz));
const b = sphere(c, 0.17, mat, [1, 1.22, 1], balloons); b.userData.base = c.clone(); b.userData.phase = i * 2.1;
const knot = sphere(c.clone().add(V(0, -0.215, 0)), 0.02, mat, [1, 0.7, 1], balloons);
const str = tube(tailTie, c.clone().add(V(0, -0.22, 0)), 0.004, M.string, balloons);
b.userData.knot = knot; b.userData.string = str; b.userData.tie = tailTie;
});
// neck + head ride in one group so the whole thing can bob
const headGroup = new THREE.Group(); pelican.add(headGroup);
const neckPts = [V(0.02, 1.10, 0), V(0.16, 1.28, 0), V(0.17, 1.46, 0), V(0.24, 1.60, 0), V(0.31, 1.70, 0)];
const neckCurve = new THREE.CatmullRomCurve3(neckPts);
const neck = new THREE.Mesh(new THREE.TubeGeometry(neckCurve, 40, 0.078, 16, false), M.feather);
neck.castShadow = true; neck.receiveShadow = true; headGroup.add(neck);
sphere(neckPts[0], 0.085, M.feather, null, headGroup);
const headC = V(0.34, 1.72, 0);
sphere(headC, 0.125, M.feather, [1.12, 1, 1], headGroup);
for (const [dx, dz, rz] of [[-0.04, 0, 0.9], [-0.08, 0.04, 1.2], [-0.08, -0.04, 1.2], [0.01, 0, 0.6]]) { // crest
const c = tube(V(headC.x + dx, headC.y + 0.10, dz), V(headC.x + dx - 0.07 * Math.cos(rz), headC.y + 0.10 + 0.09 * Math.sin(rz), dz), 0.006, M.feather, headGroup);
}
// eyes, with a brow so it looks like it means it
for (const side of [1, -1]) {
sphere(V(headC.x + 0.06, headC.y + 0.035, side * 0.105), 0.028, M.eyeWhite, null, headGroup);
sphere(V(headC.x + 0.075, headC.y + 0.035, side * 0.118), 0.016, M.eye, null, headGroup);
const brow = box(V(headC.x + 0.055, headC.y + 0.075, side * 0.108), [0.07, 0.012, 0.012], M.featherDark, headGroup);
brow.rotation.z = -0.35; brow.rotation.y = side * -0.3;
}
// straw boater, tipped a little forward
const hat = new THREE.Group(); hat.position.set(headC.x - 0.01, headC.y + 0.105, 0); hat.rotation.z = -0.14; headGroup.add(hat);
const brim = new THREE.Mesh(new THREE.CylinderGeometry(0.205, 0.205, 0.012, 40), M.hat); brim.castShadow = true; hat.add(brim);
const hatCrown = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 0.07, 40), M.hat); hatCrown.position.y = 0.04; hatCrown.castShadow = true; hat.add(hatCrown);
const band = new THREE.Mesh(new THREE.CylinderGeometry(0.123, 0.123, 0.022, 40), M.ribbon); band.position.y = 0.02; hat.add(band);
// scarf knotted at the back of the neck, tails streaming behind
const scarfT = 0.22, scarfPos = neckCurve.getPoint(scarfT), scarfTan = neckCurve.getTangent(scarfT);
const scarf = torus(scarfPos, 0.082, 0.03, M.scarf, null, headGroup);
scarf.quaternion.setFromUnitVectors(V(0, 0, 1), scarfTan);
const knotPos = scarfPos.clone().add(V(-0.10, 0.0, 0.0));
sphere(knotPos, 0.038, M.scarf, [1, 0.8, 1], headGroup);
for (const [dy, dz, rz] of [[0.06, 0.02, 0.35], [-0.03, -0.03, -0.15]]) {
const tail = box(knotPos.clone().add(V(-0.17, dy, dz)), [0.30, 0.075, 0.02], M.scarf, headGroup);
tail.rotation.z = rz; tail.rotation.y = dz * 4;
}
// beak: both mandibles hinge at B0. The upper one is a lens-section wedge; the lower
// one is two bony rami with the pouch skin stretched between them, everything sharing
// the same half-width w(t) so the pouch is attached to the bill along its whole length.
function surface(fn, nu, nv, mat, closedV) {
const pos = [], idx = [], cols = closedV ? nv : nv + 1;
for (let i = 0; i <= nu; i++) for (let j = 0; j < cols; j++) { const q = fn(i / nu, j / nv); pos.push(q.x, q.y, q.z); }
for (let i = 0; i < nu; i++) for (let j = 0; j < nv; j++) {
const j2 = closedV ? (j + 1) % nv : j + 1;
const a = i * cols + j, b = (i + 1) * cols + j, c2 = i * cols + j2, d2 = (i + 1) * cols + j2;
idx.push(a, b, c2, b, d2, c2);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setIndex(idx); g.computeVertexNormals();
const m = new THREE.Mesh(g, mat); m.castShadow = true; m.receiveShadow = true; headGroup.add(m);
return m;
}
const B0 = V(headC.x + 0.06, headC.y - 0.02, 0), BEAK_LEN = 0.64;
const aU = -0.05, aL = -0.12; // bill slightly agape at the tip
const dirU = V(Math.cos(aU), Math.sin(aU), 0), dirL = V(Math.cos(aL), Math.sin(aL), 0);
const upU = V(-Math.sin(aU), Math.cos(aU), 0), downL = V(Math.sin(aL), -Math.cos(aL), 0);
const axisU = t => B0.clone().add(dirU.clone().multiplyScalar(BEAK_LEN * t));
const axisL = t => B0.clone().add(dirL.clone().multiplyScalar(BEAK_LEN * t));
const w = t => 0.052 * (1 - t) + 0.007; // half-width of the bill
M.beak.side = THREE.DoubleSide; M.pouch.side = THREE.DoubleSide;
surface((t, v) => { // upper mandible
const phi = v * Math.PI * 2, top = 0.046 * (1 - t) + 0.005, bot = 0.010;
const sy = Math.sin(phi), y = sy > 0 ? top * sy : bot * sy;
return axisU(t).add(V(0, 0, w(t) * Math.cos(phi))).add(upU.clone().multiplyScalar(y));
}, 24, 20, M.beak, true);
const hook = new THREE.Mesh(new THREE.ConeGeometry(0.012, 0.03, 8), M.beak);
hook.position.copy(axisU(1)).add(V(0.004, -0.014, 0)); hook.rotation.z = Math.PI; headGroup.add(hook);
for (const side of [1, -1]) { // rami of the lower mandible
const ramus = new THREE.CatmullRomCurve3([0, 0.25, 0.5, 0.75, 1].map(t => axisL(t).add(V(0, 0, side * w(t)))));
const rm = new THREE.Mesh(new THREE.TubeGeometry(ramus, 16, 0.009, 8, false), M.beak);
rm.castShadow = true; headGroup.add(rm);
}
surface((t, v) => { // pouch skin between the rami
const tt = 0.02 + 0.95 * t, phi = v * Math.PI;
const depth = 0.20 * Math.pow(Math.sin(Math.PI * Math.pow(tt, 0.72)), 0.9);
const sag = 0.06 * Math.sin(Math.PI * tt); // bag leans back toward the throat
return axisL(tt)
.add(V(0, 0, w(tt) * Math.cos(phi)))
.add(downL.clone().multiplyScalar(depth * Math.sin(phi)))
.sub(dirL.clone().multiplyScalar(sag * Math.sin(phi)));
}, 28, 16, M.pouch, false);
// the fish it just caught, tail out through the gap at the tip
const fishMat = new THREE.MeshStandardMaterial({ color: 0x6fa8c9, roughness: 0.4, metalness: 0.2 });
const fishDir = V(1, -0.3, 0.3).normalize();
const fish = new THREE.Mesh(new THREE.ConeGeometry(0.032, 0.13, 3), fishMat);
fish.position.copy(axisL(0.84)).add(upU.clone().multiplyScalar(0.018)).add(fishDir.clone().multiplyScalar(0.045));
fish.quaternion.setFromUnitVectors(Y, fishDir);
fish.scale.set(1, 1, 0.45); fish.castShadow = true; headGroup.add(fish);
// legs: two-bone IK from hip to whatever the pedal is doing
const L1 = 0.31, L2 = 0.33;
const legs = [];
for (const p of pedals) {
const side = p.side;
const hip = V(-0.12, 0.90, side * 0.10);
const thigh = tube(V(0, 0, 0), V(0, 0.1, 0), 0.028, M.leg, pelican);
const shin = tube(V(0, 0, 0), V(0, 0.1, 0), 0.020, M.leg, pelican);
const knee = sphere(V(0, 0, 0), 0.028, M.leg, null, pelican);
// webbed foot: a flat fan that sits on the pedal
const fs = new THREE.Shape();
fs.moveTo(-0.03, 0); fs.lineTo(0.13, 0.05); fs.lineTo(0.10, 0.03); fs.lineTo(0.15, 0.0);
fs.lineTo(0.10, -0.03); fs.lineTo(0.13, -0.05); fs.lineTo(-0.03, 0);
const foot = new THREE.Mesh(new THREE.ExtrudeGeometry(fs, { depth: 0.012, bevelEnabled: false }), M.leg);
foot.rotation.x = -Math.PI / 2; foot.castShadow = true; pelican.add(foot);
const ankle = sphere(V(0, 0, 0), 0.022, M.leg, null, pelican);
legs.push({ hip, thigh, shin, knee, foot, ankle, pedal: p.pedal, side });
}
const tmpA = new THREE.Vector3(), tmpB = new THREE.Vector3();
function setTube(mesh, a, b) {
const d = tmpB.copy(b).sub(a), len = d.length();
mesh.position.copy(a).add(b).multiplyScalar(0.5);
mesh.scale.set(1, len / mesh.geometry.parameters.height, 1);
mesh.quaternion.setFromUnitVectors(Y, d.normalize());
}
function solveLegs() {
for (const L of legs) {
L.pedal.getWorldPosition(tmpA);
const foot = tmpA.clone(); foot.y += 0.012;
const hip = L.hip;
let dx = foot.x - hip.x, dy = foot.y - hip.y;
let d = Math.hypot(dx, dy);
const maxD = L1 + L2 - 0.01;
if (d > maxD) { dx *= maxD / d; dy *= maxD / d; d = maxD; }
const a = (L1 * L1 - L2 * L2 + d * d) / (2 * d);
const h = Math.sqrt(Math.max(0, L1 * L1 - a * a));
const ux = dx / d, uy = dy / d;
const kx = hip.x + ux * a + (-uy) * h * -1; // knee bends forward (+x)
const ky = hip.y + uy * a + (ux) * h * -1;
const knee = V(kx, ky, hip.z);
const ankle = V(hip.x + dx, hip.y + dy, hip.z);
setTube(L.thigh, hip, knee); setTube(L.shin, knee, ankle);
L.knee.position.copy(knee); L.ankle.position.copy(ankle);
L.foot.position.set(ankle.x - 0.02, ankle.y - 0.002, hip.z + 0.02);
}
}
// ---------- interaction ----------
let dragging = false, lx = 0, ly = 0;
const onDown = (x, y) => { dragging = true; lx = x; ly = y; orbit.idle = 0; };
const onMove = (x, y) => {
if (!dragging) return;
orbit.az -= (x - lx) * 0.006; orbit.el = Math.min(1.2, Math.max(-0.05, orbit.el + (y - ly) * 0.006));
lx = x; ly = y; orbit.idle = 0;
};
canvas.addEventListener('pointerdown', e => { canvas.setPointerCapture(e.pointerId); onDown(e.clientX, e.clientY); });
canvas.addEventListener('pointermove', e => onMove(e.clientX, e.clientY));
canvas.addEventListener('pointerup', () => { dragging = false; });
canvas.addEventListener('wheel', e => { orbit.dist = Math.min(12, Math.max(2.5, orbit.dist * (1 + Math.sign(e.deltaY) * 0.08))); orbit.idle = 0; e.preventDefault(); }, { passive: false });
function resize() {
const w = canvas.clientWidth, h = canvas.clientHeight;
if (canvas.width !== Math.floor(w * renderer.getPixelRatio()) || canvas.height !== Math.floor(h * renderer.getPixelRatio())) {
renderer.setSize(w, h, false);
camera.aspect = w / h; camera.updateProjectionMatrix();
}
}
// ---------- animation ----------
const SPEED = 1.5; // m/s along +x
const CADENCE = 2.6; // crank rad/s
let last = performance.now(), t = 0;
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000); last = now; t += dt;
resize();
rearWheel.rotation.z -= SPEED / R_WHEEL * dt;
frontWheel.rotation.z = rearWheel.rotation.z;
crank.rotation.z -= CADENCE * dt;
for (const p of pedals) p.pedal.rotation.z = -crank.rotation.z - p.arm.rotation.z;
for (const [s, f] of scrolling) { s.position.x -= SPEED * f * dt; if (s.position.x < -ROAD_LEN / 2) s.position.x += ROAD_LEN; }
for (const b of balloons.children) if (b.userData.base) {
b.position.copy(b.userData.base).add(V(0.03 * Math.sin(t * 0.9 + b.userData.phase), 0.04 * Math.sin(t * 1.3 + b.userData.phase), 0.02 * Math.sin(t * 0.7 + b.userData.phase)));
b.userData.knot.position.copy(b.position).add(V(0, -0.215, 0));
setTube(b.userData.string, b.userData.tie, b.userData.knot.position);
}
const bob = Math.sin(-crank.rotation.z * 2);
pelican.position.y = 0.012 * bob;
headGroup.rotation.z = 0.035 * Math.sin(-crank.rotation.z * 2 + 0.8);
solveLegs();
if (!dragging && !reduceMotion) { orbit.idle += dt; if (orbit.idle > 3.5) orbit.az += 0.10 * dt; }
placeCamera();
renderer.render(scene, camera);
requestAnimationFrame(frame);
}
placeCamera();
requestAnimationFrame(frame);
window.__pelican = { scene, camera, renderer, orbit, target, placeCamera };
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment