component
Palm
A coconut palm: leaning bowed trunk and a full crown of feathery fronds, young ones standing up and old ones arching down, deterministic from seed.
⌖ Explore the library in 3D → · View as Markdown
Add it
npx @runek/cli add palm
Pulls @react-three/rapier@^2.2.0, @runek/core@^0.15.0, three@^0.184.0.
Use it
import { Palm } from './runek/Palm'
<Palm position={[0, 0, 0]} seed={1} />
Or as a node in a world file (worlds as data):
{ "type": "Palm", "props": { "position": [0, 0, 0], "seed": 1 } }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
height |
number |
6 |
Trunk height, in units. |
lean |
number |
0.18 |
Sideways lean of the crown, as a fraction of height. |
fronds |
number |
16 |
Frond count. |
frondLength |
number |
2.8 |
Frond length, in units. |
trunkColor |
color |
palette bark |
Defaults to the world palette’s bark slot. |
frondColor |
color |
palette foliage |
Defaults to the world palette’s foliage slot. |
coconuts |
boolean |
true |
Grow a coconut cluster under the crown. |
position |
[x, y, z] |
[0, 0, 0] |
Position [x, y, z] in units (1 unit = 1 m, Y-up). |
rotation |
[x, y, z] |
[0, 0, 0] |
Euler rotation [x, y, z] in radians. |
seed |
number |
1 |
Seed for the deterministic variation: same seed, same result. |
Registry manifest
registry manifest /r/components/palm.json Self-contained JSON: inlined source plus resolved dependencies, exactly whatrunek add palm fetches.
Browse the whole catalog in the gallery →.
Source (Palm.tsx)
import { CylinderCollider, RigidBody } from '@react-three/rapier'
import { rng, useWorld, type WorldComponentProps } from '@runek/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
export interface PalmProps extends WorldComponentProps {
/** Trunk height, in units. */
height?: number
/** Sideways lean of the crown, as a fraction of height. */
lean?: number
/** Frond count. */
fronds?: number
/** Frond length, in units. */
frondLength?: number
/** Defaults to the world palette's `bark` slot. */
trunkColor?: string
/** Defaults to the world palette's `foliage` slot. */
frondColor?: string
/** Grow a coconut cluster under the crown. */
coconuts?: boolean
}
const TRUNK_SEGMENTS = 7
const UP = new THREE.Vector3(0, 1, 0)
// Samples along each frond's midrib, and leaflets on each side of it.
const RACHIS_STEPS = 14
const LEAFLETS = 22
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5))
interface Frond {
/** Compass direction the frond grows toward. */
yaw: number
/** Angle above horizontal where the frond leaves the crown, in radians. */
rise: number
/** How far the midrib arcs down by its tip, in radians. */
bend: number
length: number
color: THREE.Color
}
/**
* A whole crown as one mesh: each frond is an arching midrib with a row of leaflets hanging off
* either side in a shallow V, so the crown reads as feathery from any angle.
*/
function crownGeometry(fronds: Frond[]) {
const positions: number[] = []
const colors: number[] = []
const tri = (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3, ca: THREE.Color, cb = ca) => {
positions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z)
colors.push(ca.r, ca.g, ca.b, ca.r, ca.g, ca.b, cb.r, cb.g, cb.b)
}
const out = new THREE.Vector3()
const side = new THREE.Vector3()
for (const f of fronds) {
out.set(Math.cos(f.yaw), 0, Math.sin(f.yaw))
side.set(-Math.sin(f.yaw), 0, Math.cos(f.yaw))
// Frond-local (along, up, across) to crown space.
const at = (x: number, y: number, z: number) =>
new THREE.Vector3().addScaledVector(out, x).addScaledVector(UP, y).addScaledVector(side, z)
// The midrib leaves at `rise` and curls down harder toward its tip.
const spine: { x: number; y: number; angle: number }[] = []
let x = 0
let y = 0
const ds = f.length / RACHIS_STEPS
for (let k = 0; k <= RACHIS_STEPS; k++) {
const angle = f.rise - f.bend * (k / RACHIS_STEPS) ** 1.6
spine.push({ x, y, angle })
x += Math.cos(angle) * ds
y += Math.sin(angle) * ds
}
const sample = (u: number) => {
const i = Math.min(Math.floor(u * RACHIS_STEPS), RACHIS_STEPS - 1)
const t = u * RACHIS_STEPS - i
const a = spine[i]
const b = spine[i + 1]
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
angle: a.angle + (b.angle - a.angle) * t,
}
}
const rib = f.color.clone().offsetHSL(-0.03, -0.1, 0.06)
const ribWidth = f.length * 0.018
for (let k = 0; k < RACHIS_STEPS; k++) {
const a = spine[k]
const b = spine[k + 1]
const wa = ribWidth * (1 - (0.7 * k) / RACHIS_STEPS)
const wb = ribWidth * (1 - (0.7 * (k + 1)) / RACHIS_STEPS)
const a0 = at(a.x, a.y, -wa)
const a1 = at(a.x, a.y, wa)
const b0 = at(b.x, b.y, -wb)
const b1 = at(b.x, b.y, wb)
tri(a0, b0, a1, rib)
tri(a1, b0, b1, rib)
}
const tipColor = f.color.clone().offsetHSL(0.01, 0, 0.07)
const longest = f.length * 0.3
for (let j = 0; j < LEAFLETS; j++) {
const u = 0.12 + (0.86 * (j + 0.5)) / LEAFLETS
const p = sample(u)
const reach = longest * Math.sin(Math.PI * (0.15 + 0.85 * u)) ** 0.6
const width = longest * 0.055
const tangent = new THREE.Vector3(Math.cos(p.angle), Math.sin(p.angle), 0)
const below = new THREE.Vector3(Math.sin(p.angle), -Math.cos(p.angle), 0)
for (const s of [-1, 1]) {
// Out to the side, angled forward along the midrib, hanging below it; the outer half
// sags further, like a leaflet bending under its own weight.
const dir = (droop: number) =>
new THREE.Vector3(0, 0, s * Math.cos(droop))
.addScaledVector(below, Math.sin(droop))
.multiplyScalar(Math.cos(0.55))
.addScaledVector(tangent, Math.sin(0.55))
.normalize()
const inner = dir(0.6)
const outer = dir(1.1)
const base = new THREE.Vector3(p.x, p.y, 0)
const mid = base.clone().addScaledVector(inner, reach * 0.5)
const tip = mid.clone().addScaledVector(outer, reach * 0.5)
const spread = tangent
.clone()
.addScaledVector(inner, -tangent.dot(inner))
.normalize()
.multiplyScalar(width)
const B = at(base.x, base.y, base.z)
const L = at(mid.x + spread.x, mid.y + spread.y, mid.z + spread.z)
const R = at(mid.x - spread.x, mid.y - spread.y, mid.z - spread.z)
const T = at(tip.x, tip.y, tip.z)
tri(B, L, T, f.color, tipColor)
tri(B, T, R, f.color, tipColor)
}
}
}
const g = new THREE.BufferGeometry()
g.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
g.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))
g.computeVertexNormals()
return g
}
/**
* A coconut palm: a leaning, gently bowed trunk topped with a full crown of feathery fronds,
* the young ones standing up and the old ones arching over and down. Trunk rings are instanced
* and the crown is one mesh; the seed drives lean direction, frond spread, and coconut
* placement. One trunk cylinder collider.
*/
export function Palm({
position = [0, 0, 0],
rotation = [0, 0, 0],
seed = 1,
height = 6,
lean = 0.18,
fronds = 16,
frondLength = 2.8,
trunkColor,
frondColor,
coconuts = true,
}: PalmProps) {
const { unit, palette } = useWorld()
const bark = trunkColor ?? palette.bark
const leaf = frondColor ?? palette.foliage
const h = height * unit
const trunkRef = useRef<THREE.InstancedMesh>(null)
const { rings, crown, crownYaw, nuts, frondPose } = useMemo(() => {
const next = rng(seed)
const yaw = next() * Math.PI * 2
const bow = lean * h
const tip = new THREE.Vector3(Math.cos(yaw) * bow, h, Math.sin(yaw) * bow)
const ctrl = new THREE.Vector3(Math.cos(yaw) * bow * 0.15, h * 0.55, Math.sin(yaw) * bow * 0.15)
const curve = new THREE.QuadraticBezierCurve3(new THREE.Vector3(0, 0, 0), ctrl, tip)
const rings: { pos: THREE.Vector3; quat: THREE.Quaternion; len: number; r: number }[] = []
for (let i = 0; i < TRUNK_SEGMENTS; i++) {
const t0 = i / TRUNK_SEGMENTS
const t1 = (i + 1) / TRUNK_SEGMENTS
const a = curve.getPoint(t0)
const b = curve.getPoint(t1)
const dir = b.clone().sub(a)
const len = dir.length() * 1.15 // overlap hides the joints
rings.push({
pos: a.clone().add(b).multiplyScalar(0.5),
quat: new THREE.Quaternion().setFromUnitVectors(UP, dir.normalize()),
len,
r: (0.16 - 0.07 * t0) * unit * (height / 6),
})
}
// Spiralled round the crown, youngest first: the top fronds stand up, the middle ones arch
// out and over, the oldest hang low and turn a little yellow.
const frondPose: Frond[] = Array.from({ length: fronds }, (_, i) => {
const age = fronds > 1 ? i / (fronds - 1) : 0.5
return {
color: new THREE.Color(leaf).offsetHSL(
-0.05 * age + (next() - 0.5) * 0.02,
0,
0.05 * age - 0.02,
),
yaw: i * GOLDEN_ANGLE + (next() - 0.5) * 0.4,
rise: 1.15 - 1.35 * age + (next() - 0.5) * 0.25,
bend: 0.5 + 0.95 * age + next() * 0.25,
length: frondLength * unit * (0.7 + 0.35 * age) * (0.9 + next() * 0.2),
}
})
const nuts = Array.from({ length: 3 }, () => {
const a = next() * Math.PI * 2
const r = 0.16 * unit
return new THREE.Vector3(Math.cos(a) * r * 1.6, -0.12 * unit, Math.sin(a) * r * 1.6)
})
return { rings, crown: tip, crownYaw: yaw, nuts, frondPose }
}, [seed, h, lean, fronds, frondLength, leaf, unit, height])
const crownGeo = useMemo(() => crownGeometry(frondPose), [frondPose])
useLayoutEffect(() => {
const mesh = trunkRef.current
if (!mesh) return
const dummy = new THREE.Object3D()
rings.forEach((ring, i) => {
dummy.position.copy(ring.pos)
dummy.quaternion.copy(ring.quat)
dummy.scale.set(ring.r, ring.len, ring.r)
dummy.updateMatrix()
mesh.setMatrixAt(i, dummy.matrix)
})
mesh.instanceMatrix.needsUpdate = true
}, [rings])
return (
<RigidBody type="fixed" colliders={false} position={position} rotation={rotation}>
<CylinderCollider args={[h / 2, 0.2 * unit]} position={[0, h / 2, 0]} />
<instancedMesh
key={`t${rings.length}`}
ref={trunkRef}
args={[undefined, undefined, rings.length]}
castShadow
>
<cylinderGeometry args={[0.82, 1, 1, 7]} />
<meshStandardMaterial color={bark} roughness={0.95} flatShading />
</instancedMesh>
<mesh geometry={crownGeo} position={crown} castShadow>
<meshStandardMaterial vertexColors roughness={0.9} side={THREE.DoubleSide} flatShading />
</mesh>
{/* The knot of frond bases the crown grows from. */}
<mesh position={crown} castShadow>
<sphereGeometry args={[0.17 * unit * (height / 6), 8, 6]} />
<meshStandardMaterial color={bark} roughness={1} flatShading />
</mesh>
{coconuts && (
<group position={[crown.x, crown.y, crown.z]} rotation={[0, -crownYaw, 0]}>
{nuts.map((n, i) => (
<mesh key={`${i * 7}`} position={n} castShadow>
<sphereGeometry args={[0.11 * unit, 8, 6]} />
<meshStandardMaterial color="#6b5233" roughness={1} />
</mesh>
))}
</group>
)}
</RigidBody>
)
}
Palm.groundSitting = true