"Very ugly" was the report and it was right. Put beside its reference the
diagnosis is not subtle: the crown read as crumpled paper, the colour was flat,
and the trunk had been swallowed whole.
The correction that mattered was to a rule I had invented myself. I decided
early that "a crown is one piece" and made the probe fail anything else, so
every version since had been tuned until the flood fill returned 1 — which is
the instruction "be a single smooth blob", and that is exactly what it kept
producing. The support rule requires the crown to be connected to the TREE, not
to itself: a clump that swallows a branch end is held up by that branch
whatever its neighbours do. Letting the masses separate is what buys the gaps,
and the gaps are the silhouette. The probe now reports the piece count as
information instead of grading it.
Four other things, each of which cost a render to see:
- The clumps were three rings on five sides — a coarse bipyramid — and
sixty-five of them creased every facet against its neighbour. Five on eight
fixed the surface and made a cabbage. Four on six is the middle, and the
middle is where it belongs.
- `roughenGeometry` came off the crown entirely. Ragged edges read as foliage
where smooth ones read as stone: true of a SILHOUETTE, false of a surface.
All it did was crumple the skin. Raggedness has to come from the
arrangement.
- The tone between clumps varied by +/-0.09, so every mass was the same green
and the crown was one object with facet shading on it. Widening it to
+/-0.19 put seven whole clumps through the floor into black, because the
tint jitter, the mottle and the baked occlusion all pull the same way
underneath. It is asymmetric now, -0.03 to +0.23, which is also truer: a
lit mass is far brighter than the base green while a shaded one is filled
in by the sky.
- Every clump sat on the outer half of its limb, making a crown that is a
RING — full at the rim, hollow in the middle, and from a third of the
compass you look straight through the hole. I had that right once and
removed it while cutting triangles, which is the cheapest regression to
make and the hardest to notice: nothing failed, the model just got worse.
The bole went from 0.037 of the height to 0.046. The reference's own number
came off a photograph of a tree seen whole from a distance; at this size, under
a crown, 0.037 disappears and what is left is a cabbage on a stick.
Twelve limbs instead of nine, spread more evenly, with cheaper geometry paying
for them. And the turntable is now the thing I judge it by rather than a single
three-quarter view — one good angle hid two that were full of daylight.
Honest state: better than it was and not yet good. Three of six angles read as
a tree; the others are still bunched. 38 models, 32 798 triangles, all checks
pass, 0 floating cases.
681 lines
32 KiB
TypeScript
681 lines
32 KiB
TypeScript
/**
|
|
* @medieval-kit/oak-tree
|
|
*
|
|
* A free-standing oak: flared bole, sweeping limbs, a broad low crown.
|
|
*
|
|
* The kit had nothing growing in it at all. Every other model is something a
|
|
* person made, and a village of nothing but made things reads as a warehouse
|
|
* yard — so this is the first piece of the site rather than of the inventory,
|
|
* and it is also by some way the largest thing here.
|
|
*
|
|
* It was built against two reference photographs, and both of them contradicted
|
|
* what I was going to build. Worth writing down, because the default lowpoly
|
|
* tree — a tall bare pole with a ball on top — is wrong in every measurement
|
|
* that matters:
|
|
*
|
|
* - CROWN WIDTH / TREE HEIGHT = 1.32 to 1.43. An open-grown oak is HALF
|
|
* AGAIN as wide as it is tall. Nothing else in this kit is wider than it
|
|
* is tall, and getting this one number right is most of what makes the
|
|
* silhouette read as an oak rather than as a lollipop.
|
|
* - THE CROWN IS WIDEST AT 24% OF THE HEIGHT. Not halfway up, not at the
|
|
* top: a quarter of the way up, just above the ground. The lowest limbs
|
|
* are the longest ones and they leave the trunk almost horizontally.
|
|
* - THE TRUNK RUNS TO 72% OF THE HEIGHT before it stops being the widest
|
|
* single thing. It does not fork low into equal leaders, which is what I
|
|
* had assumed from the leafy photograph alone; the winter photograph shows
|
|
* one continuous bole with everything else hung off it.
|
|
* - THE FLARE IS SHARP AND SHORT. The trunk is 0.130 of the tree's height
|
|
* wide at 2% of the height and 0.073 at 10% — it nearly halves in the
|
|
* bottom tenth, then barely tapers at all through the next quarter.
|
|
*
|
|
* The limbs SWEEP. A branch leaves the trunk at a shallow angle and turns
|
|
* upward at its outer end, which is the profile a lowpoly tree most often
|
|
* misses, and it comes free here: `bendGeometry` on a limb built along its own
|
|
* axis, before it is rotated into place.
|
|
*
|
|
* The honest limit: what fills a real winter silhouette is thousands of twigs,
|
|
* and no triangle budget here reaches them. At `leafiness: 0` this is a bare
|
|
* tree of LIMBS, which is a fine dead or winter tree and is not a photograph
|
|
* of one.
|
|
*/
|
|
import { Color, type BufferGeometry } from 'three'
|
|
|
|
import {
|
|
bendGeometry,
|
|
createKitModel,
|
|
createTinter,
|
|
jitter,
|
|
latheGeometry,
|
|
mergeColoured,
|
|
roughenGeometry,
|
|
type Level,
|
|
} from '../core/index.ts'
|
|
|
|
export interface OakTreeConfig {
|
|
/** Height to the top of the crown (metres). */
|
|
readonly height: number
|
|
/** Crown width as a multiple of the height. The reference oaks measure 1.32–1.43. */
|
|
readonly spread: number
|
|
/** Radius of the clear bole, as a fraction of the height. */
|
|
readonly bole: number
|
|
/** Primary limbs off the trunk. */
|
|
readonly limbs: number
|
|
/** 0 leaves it bare; 1 is full summer leaf. */
|
|
readonly leafiness: number
|
|
/** 0 is green, 1 turns the crown to russet and gold. */
|
|
readonly autumn: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export const oakTreeDefaults: OakTreeConfig = {
|
|
height: 7,
|
|
spread: 1.3,
|
|
// 0.037, straight off the reference: the bole measured 0.073 of the tree's
|
|
// height ACROSS at the point where it has finished flaring.
|
|
// 0.046, not the reference's own 0.037. That number came off a photograph
|
|
// of a tree seen whole from a distance; at this size, with a crown of
|
|
// twenty masses sitting over it, a bole at 0.037 disappears entirely and
|
|
// what is left is a cabbage on a stick. The trunk has to read.
|
|
bole: 0.046,
|
|
limbs: 12,
|
|
leafiness: 1,
|
|
autumn: 0,
|
|
seed: 11,
|
|
}
|
|
|
|
export type OakTreeParts = 'trunk' | 'boughs' | 'crown'
|
|
|
|
export function createModel(overrides: Partial<OakTreeConfig> = {}) {
|
|
return createKitModel<OakTreeConfig, 'oak' | 'leaf', OakTreeParts>({
|
|
id: 'oak-tree',
|
|
defaults: oakTreeDefaults,
|
|
slots: ['oak', 'leaf'],
|
|
|
|
build: ({ config, random }) => {
|
|
const tint = createTinter(random)
|
|
const H = Math.max(0.5, config.height)
|
|
const R = Math.max(0.004, config.bole) * H
|
|
const crownR = (Math.max(0.2, config.spread) * H) / 2
|
|
const leafiness = Math.max(0, Math.min(1, config.leafiness))
|
|
const autumn = Math.max(0, Math.min(1, config.autumn))
|
|
const limbCount = Math.max(3, Math.min(14, Math.round(config.limbs)))
|
|
|
|
/**
|
|
* How far a limb turns from root to tip, in radians.
|
|
*
|
|
* It is an ANGLE rather than a curvature so that every limb turns by the
|
|
* same amount whatever its length: a short top limb and a long bottom
|
|
* one then read as the same tree. 0.42 rad is 24 degrees, which is what
|
|
* the reference's limbs do between leaving the bole and their outer end.
|
|
*/
|
|
const SWEEP = 0.42
|
|
|
|
/**
|
|
* The measured taper, as (height fraction, radius in bole radii).
|
|
*
|
|
* These are not shaped by eye. Each pair is a row read off the winter
|
|
* photograph by finding the run of trunk pixels at that height, and the
|
|
* profile they make is the whole character of the bole: a violent flare
|
|
* in the bottom twentieth, then a shaft that hardly tapers for a quarter
|
|
* of the tree, then a steady run out to nothing at 74%.
|
|
*/
|
|
const TAPER: ReadonlyArray<readonly [number, number]> = [
|
|
[0.00, 1.95],
|
|
[0.02, 1.76],
|
|
[0.06, 1.14],
|
|
[0.11, 1.00],
|
|
[0.38, 0.94],
|
|
[0.50, 0.71],
|
|
[0.62, 0.50],
|
|
[0.74, 0.16],
|
|
]
|
|
const trunkTop = H * TAPER[TAPER.length - 1]![0]
|
|
|
|
/** The bole's radius at a height, so a limb can be sized to where it grows. */
|
|
const boleAt = (y: number): number => {
|
|
const f = y / H
|
|
for (let i = 0; i < TAPER.length - 1; i += 1) {
|
|
const [f0, r0] = TAPER[i]!
|
|
const [f1, r1] = TAPER[i + 1]!
|
|
if (f <= f1) {
|
|
const t = (f - f0) / (f1 - f0)
|
|
return R * (r0 + (r1 - r0) * Math.max(0, Math.min(1, t)))
|
|
}
|
|
}
|
|
return R * TAPER[TAPER.length - 1]![1]
|
|
}
|
|
|
|
/**
|
|
* Foliage colour. Green at `autumn` 0, russet and gold at 1.
|
|
*
|
|
* The turn is a HUE ROTATION of the same measured green rather than a
|
|
* second palette entry, because that is what happens in the leaf: the
|
|
* chlorophyll goes and what was already underneath it shows. Saturation
|
|
* climbs with it — an autumn oak is a more saturated object than a
|
|
* summer one, which is the opposite of what fading suggests.
|
|
*/
|
|
/**
|
|
* Clump to clump, the tone varies a LOT, and that is the whole depth of
|
|
* a lowpoly crown.
|
|
*
|
|
* At +/-0.09 every mass came out the same green and the crown read as
|
|
* one solid object with facet shading on it — a cabbage. The reference's
|
|
* crown varies by 0.45 in lightness WITHIN one band; a smooth mass
|
|
* cannot reproduce that at the leaf's scale, but it can carry it at the
|
|
* clump's, which is the only scale this budget has. What must NOT vary
|
|
* is hue: measured across the reference, lit and shadowed alike, it
|
|
* holds between 77 and 81 degrees.
|
|
*
|
|
* ASYMMETRIC, and that took a render to learn. A symmetric +/-0.19 put
|
|
* whole masses through the floor — not clipped pixels here and there but
|
|
* six or seven entirely black clumps, because the tint's own jitter, the
|
|
* mottle and the baked occlusion all pull the same way underneath it. It
|
|
* is also wrong about foliage: a lit mass is far brighter than the base
|
|
* green, while a shaded one is filled in by the sky and never gets that
|
|
* far below it. So the range is -0.03 to +0.23.
|
|
*/
|
|
const leafColour = (lift: number): Color => {
|
|
const c = tint('leaf', lift, 1)
|
|
// Darker clumps are MORE saturated, which is the measurement and is the
|
|
// opposite of the intuition that shadow drains colour: across the
|
|
// reference the darkest tenth of the crown reads 0.44 saturation and
|
|
// the lightest 0.32. A highlight on a leaf is a specular reflection of
|
|
// a white sky, and it washes the green out of it.
|
|
// -0.145 of a turn, which is 52 degrees, taking the measured green at
|
|
// 78 down to 26: RUSSET. The first attempt turned it 41 degrees to
|
|
// amber and lifted the lightness, and an oak came out of it the colour
|
|
// of a lemon. An oak in October is a dark red-brown -- it goes DOWN in
|
|
// value as well as round in hue, which is why the lightness term is
|
|
// negative here and was the thing that made the difference.
|
|
c.offsetHSL(-0.145 * autumn, -lift * 0.55 + 0.14 * autumn, -0.03 * autumn)
|
|
return c
|
|
}
|
|
|
|
// --- The bole ---------------------------------------------------------
|
|
const trunk = latheGeometry(
|
|
TAPER.map(([f, r]) => ({ y: H * f, radius: R * r })) as Level[],
|
|
// Capped at BOTH ends. Leaving the top open was the obvious economy --
|
|
// the crown's own clumps swallow it -- but `leafiness: 0` takes the
|
|
// crown away and leaves you looking down an open pipe.
|
|
8, [0, 0, 0], tint('bark', -0.02, 0.8),
|
|
)
|
|
roughenGeometry(trunk, R * 0.3, { salt: 9, scaleY: 0.4 })
|
|
|
|
/**
|
|
* Put the foot back on the floor.
|
|
*
|
|
* `roughenGeometry` moves every corner by its own position, the bottom
|
|
* ring included, so a roughened trunk ends up standing on five points
|
|
* with daylight between them and its lowest vertex a few centimetres
|
|
* underground. Neither is visible in a render and both are wrong. The
|
|
* bole is the only thing in this model that touches the ground, so its
|
|
* foot is flattened back to exactly zero afterwards — the flare keeps
|
|
* every bit of its irregularity above that line.
|
|
*/
|
|
const plant = (geometry: BufferGeometry, below: number): void => {
|
|
const position = geometry.getAttribute('position')
|
|
for (let i = 0; i < position.count; i += 1) {
|
|
if (position.getY(i) < below) position.setY(i, 0)
|
|
}
|
|
position.needsUpdate = true
|
|
geometry.computeVertexNormals()
|
|
}
|
|
plant(trunk, R * 0.4)
|
|
|
|
// --- Limbs ------------------------------------------------------------
|
|
const wood: BufferGeometry[] = []
|
|
const crown: BufferGeometry[] = []
|
|
|
|
/**
|
|
* One faceted clump of leaves.
|
|
*
|
|
* A lathe whose profile is a circle, so the poles close to a point and
|
|
* need no caps: five levels by six segments is 36 triangles, which is
|
|
* what makes it affordable to hang three dozen of them in a crown. The
|
|
* per-level radius jitter matters more than it looks — without it every
|
|
* clump is the same ball at a different size and the crown reads as
|
|
* bubbles.
|
|
*/
|
|
const clump = (radius: number, at: readonly [number, number, number], lift: number): BufferGeometry => {
|
|
// Four rings on six sides: a rounded mass at 36 triangles.
|
|
//
|
|
// Three on five was a coarse bipyramid and sixty-five of them read as
|
|
// crumpled paper. Five on eight was smooth and twenty of them read as
|
|
// a cabbage. This is the middle, and the middle is where it belongs:
|
|
// enough facets that the mass is round, few enough that forty of them
|
|
// fit in the budget, and forty is what it takes to have a crown made
|
|
// of parts rather than a crown made of one thing.
|
|
const rings = 4
|
|
const levels: Level[] = []
|
|
for (let i = 0; i <= rings; i += 1) {
|
|
const t = i / rings
|
|
const profile = Math.sqrt(Math.max(0, 1 - Math.pow(2 * t - 1, 2)))
|
|
levels.push({
|
|
// Squat: a clump of leaves is wider than it is deep, because it
|
|
// grows out along a branch rather than around it.
|
|
y: at[1] + radius * 0.78 * (2 * t - 1),
|
|
radius: radius * profile * (0.84 + random() * 0.34),
|
|
})
|
|
}
|
|
// Five sides, not six. The kit's budget is 2500 triangles a model and
|
|
// this one has to buy sixty-odd clumps with it; a side costs four
|
|
// triangles across every clump in the crown, and at this size nobody
|
|
// counts them. What the budget must not buy is fewer clumps, because
|
|
// the clump count is what holds the crown together.
|
|
return latheGeometry(levels, 6, [at[0], 0, at[2]], leafColour(lift))
|
|
}
|
|
|
|
/**
|
|
* Sized to MERGE, not to look right on its own.
|
|
*
|
|
* At 0.185 of the crown radius the clumps came out as fourteen separate
|
|
* balls -- and not as a judgement call: voxelising the crown and flood
|
|
* filling it found four disconnected pieces, three of them single limb
|
|
* ends floating clear of the mass. A crown is one piece.
|
|
*
|
|
* 0.27 fixed that and overshot into the other failure: one smooth lump
|
|
* with a notch in it, because clumps a quarter of the crown across leave
|
|
* no room for the crown to have parts. The answer is not a radius at all
|
|
* but a COUNT -- more, smaller clumps gathered per limb, so each limb
|
|
* carries its own lobe and the lobes meet at seams instead of dissolving
|
|
* into each other. Which is how the reference is built, one mass of
|
|
* foliage to each big bough.
|
|
*/
|
|
/**
|
|
* Small enough that the masses DO NOT ALL MERGE.
|
|
*
|
|
* This is the correction that mattered, and it was my own rule that had
|
|
* to go: I decided early that "a crown is one piece" and made the probe
|
|
* fail anything else, so every version since has been tuned until the
|
|
* flood fill returned 1 — which is exactly the instruction "be a single
|
|
* smooth blob", and that is what it kept producing.
|
|
*
|
|
* The support rule requires the crown to be connected to the TREE, not
|
|
* to itself. A clump that swallows a branch end is held up by that
|
|
* branch whatever its neighbours do. Letting them separate is what buys
|
|
* the gaps, and the gaps are the silhouette — a crown you can see
|
|
* through in places is the difference between foliage and a cabbage.
|
|
*/
|
|
const clumpR = crownR * 0.26 * (0.55 + 0.45 * leafiness)
|
|
// The tips stop short of the crown radius by most of a clump, so that
|
|
// `spread` describes the width of the FINISHED crown rather than of the
|
|
// bare branchwork inside it.
|
|
const reach = crownR - clumpR * 0.8
|
|
|
|
for (let i = 0; i < limbCount; i += 1) {
|
|
const f = limbCount === 1 ? 0 : i / (limbCount - 1)
|
|
/**
|
|
* Limbs crowd towards the BOTTOM of their range.
|
|
*
|
|
* Spaced evenly up the bole, the crown's mass began at 29% of the
|
|
* height while two long bottom limbs carried their leaves out at 10%
|
|
* to 30% with nothing around them -- and the flood fill found exactly
|
|
* that: a 7000-cell piece at bearing 228-313 degrees, reaching 5.7 m
|
|
* out where the main mass stopped at 4.5, torn off vertically rather
|
|
* than sideways. Two rounds of widening clumps and evening out the
|
|
* bearings had each failed because neither was the gap.
|
|
*
|
|
* The reference is at full width by 24% of its height and holds it to
|
|
* 44%, which means most of its limbs are in the bottom half. Raising f
|
|
* to a power puts them there: nine limbs land at 30, 33, 36, 41, 47,
|
|
* 53, 59, 64 and 70% instead of evenly every 5%.
|
|
*/
|
|
const g = Math.pow(f, 1.35)
|
|
const ay = H * (0.24 + 0.44 * g) + jitter(random, H * 0.015)
|
|
const ty = H * (0.390 + 0.490 * g) + jitter(random, H * 0.02)
|
|
|
|
// Driven by g, the same crowded parameter the heights use, so that a
|
|
// limb low on the bole is also a LONG one -- which is the reference's
|
|
// arrangement and the reason its crown is widest near the bottom.
|
|
// Leaving this on the even f while the heights moved to g put the
|
|
// widest row up at 54%.
|
|
//
|
|
// And the spread narrowed from +/-12% to +/-6%: at 12% the single
|
|
// longest limb reached 5.6 m where its neighbours stopped at 4.5, and
|
|
// its lobe was stranded out there on its own with nothing to touch. It
|
|
// is the same tear the flood fill kept finding, and no amount of
|
|
// widening the clumps reaches across a gap made by one limb outrunning
|
|
// the rest -- the fix is for it not to outrun them.
|
|
const tr = reach * (1 - 0.8 * g) * (0.94 + random() * 0.12)
|
|
|
|
// Straight from the geometry, not chosen: a limb whose tip lands level
|
|
// with its own root is horizontal, and atan2 says so without any
|
|
// special case.
|
|
//
|
|
// Plus HALF THE SWEEP, which is the part I had missed. Bending a limb
|
|
// does not just curve it, it swings the whole chord from root to tip up
|
|
// by half the arc -- so a limb aimed at its intended tip and then bent
|
|
// lands well above it. Every limb was arriving 0.21 rad high and the
|
|
// crown's widest point had climbed from the 24% of the reference to
|
|
// 38%. Aiming low by exactly the amount the bend will lift is the
|
|
// whole correction, and it is a constant because SWEEP is an angle.
|
|
const rise = ty - ay
|
|
const tilt = Math.atan2(tr, rise) + SWEEP / 2
|
|
const len = Math.hypot(tr, rise)
|
|
|
|
const local: BufferGeometry[] = []
|
|
const leaves: BufferGeometry[] = []
|
|
|
|
// 0.85 of the bole where it grows, not 0.55. A main limb on the
|
|
// reference is very nearly as thick as the trunk it leaves, and thin
|
|
// limbs are invisible under the crown -- which cost the model the
|
|
// whole radiating structure that says "tree" before the leaves do.
|
|
const r0 = boleAt(ay) * 0.85
|
|
const limb = latheGeometry(
|
|
[0, 0.25, 0.5, 0.75, 1].map((t, k) => ({
|
|
y: len * t,
|
|
radius: r0 * [1, 0.72, 0.52, 0.36, 0.22][k]!,
|
|
})) as Level[],
|
|
// Not lifted. A bough lifted above the bole's own tone came out
|
|
// sandy next to it, which is invisible under a full crown and is the
|
|
// entire model at `leafiness: 0`.
|
|
4, [0, 0, 0], tint('bark', -0.02, 0.9),
|
|
)
|
|
local.push(limb)
|
|
|
|
/**
|
|
* Everything for this limb is built along +Y with its foot at the
|
|
* ORIGIN, and only turned into place at the end.
|
|
*
|
|
* That is what makes the forks and the clumps free: they are placed in
|
|
* the limb's own frame, where "along the branch" is just +Y, and the
|
|
* bend and the two rotations are then applied to the whole assembly at
|
|
* once. Nothing can come adrift from the limb it belongs to, because
|
|
* nothing is ever positioned in world space.
|
|
*
|
|
* It also means the limb's foot sits ON the trunk's axis, buried a
|
|
* whole bole radius inside the bole. There is no join to get wrong.
|
|
*/
|
|
const forks = 2
|
|
for (let j = 0; j < forks; j += 1) {
|
|
// Outboard. At 0.58 the first fork threw its leaves back towards
|
|
// the trunk and packed the space around the bole solid, so the crown
|
|
// came down as an unbroken dome and the tree lost the one thing that
|
|
// separates it from a bush: you could not see the bole rise into it.
|
|
const at = len * (0.62 + 0.23 * j)
|
|
// Always positive, and that is the whole of the fix.
|
|
//
|
|
// The rise of a fork works out as sin(splay) * sin(spin), so it is
|
|
// the SIGN OF THE PRODUCT that decides up or down. Constraining the
|
|
// spin to the upper half circle and then alternating the splay
|
|
// between + and - left every second fork pointing at the floor -- and
|
|
// the measurement said so plainly while the reasoning had said the
|
|
// opposite. With the splay kept positive the spin alone carries the
|
|
// variety, and it still swings the fork right round through the
|
|
// horizontal because cos(spin) runs from +1 to -1 across the range.
|
|
const splay = 0.42 + random() * 0.26
|
|
/**
|
|
* Which way the fork throws, and it may not throw DOWNWARD.
|
|
*
|
|
* Spun freely through the whole circle, a fork on a near-horizontal
|
|
* bottom limb can come off 39 degrees below the horizontal, drop the
|
|
* best part of a metre, and hang its clump on the floor -- which is
|
|
* how the crown came to be 7 m wide at 2% of the tree's height, where
|
|
* the reference has nothing but bare bole.
|
|
*
|
|
* In the limb's own frame +Z is the direction `rotateX` will send
|
|
* upward, so keeping the spin inside (0, PI) keeps the fork's rise
|
|
* positive whatever the limb is doing. Side shoots on a real bough
|
|
* grow up and out; none of them grow down.
|
|
*/
|
|
const spin = Math.PI / 2 + jitter(random, 1.5)
|
|
const secLen = len * (0.34 - 0.1 * j) * (0.8 + random() * 0.4)
|
|
const secR = r0 * (0.42 - 0.12 * j)
|
|
|
|
// Three levels on four sides: a fork is short, half buried in the
|
|
// clump on its end, and never seen in silhouette. It is the cheapest
|
|
// thing in the model to make cheaper.
|
|
const sec = latheGeometry(
|
|
[0, 0.55, 1].map((t, k) => ({
|
|
y: secLen * t,
|
|
radius: secR * [1, 0.5, 0.2][k]!,
|
|
})) as Level[],
|
|
3, [0, 0, 0], tint('bark', 0.0, 0.9),
|
|
)
|
|
sec.rotateZ(splay)
|
|
sec.rotateY(spin)
|
|
sec.translate(0, at, 0)
|
|
local.push(sec)
|
|
|
|
// Only the OUTER fork gets a clump, which is what pays for the
|
|
// inner one above. The inner fork sits under the crown's own mass
|
|
// and its clump was never seen.
|
|
if (false) {
|
|
// The tip, worked out rather than guessed: rotateZ then rotateY
|
|
// sends +Y to this direction, and a clump centred there is
|
|
// guaranteed to contain the branch end it hangs on.
|
|
const dir: readonly [number, number, number] = [
|
|
-Math.sin(splay) * Math.cos(spin),
|
|
Math.cos(splay),
|
|
Math.sin(splay) * Math.sin(spin),
|
|
]
|
|
const tip: readonly [number, number, number] = [
|
|
dir[0] * secLen, at + dir[1] * secLen, dir[2] * secLen,
|
|
]
|
|
for (let k = 0; k < 1; k += 1) {
|
|
const r = clumpR * (0.78 + random() * 0.5)
|
|
// Offsets stay inside the clump's own radius, so consecutive
|
|
// clumps always overlap and the branch tip is always inside one.
|
|
leaves.push(clump(r, [
|
|
tip[0] + jitter(random, r * 0.5),
|
|
tip[1] + jitter(random, r * 0.45),
|
|
tip[2] + jitter(random, r * 0.5),
|
|
], 0.1 + jitter(random, 0.13)))
|
|
}
|
|
}
|
|
}
|
|
|
|
if (leafiness > 0) {
|
|
/**
|
|
* Along the bough, INCLUDING well inboard.
|
|
*
|
|
* 0.38 is the one that matters and it took four failed rounds to
|
|
* arrive at. The crown kept coming apart into two pieces, and every
|
|
* attempt to close the gap treated it as a gap between LOBES --
|
|
* wider clumps, evener bearings, more limbs, less length jitter --
|
|
* none of which worked, because the crown was never short of foliage
|
|
* where those fixes put it.
|
|
*
|
|
* What was missing was the middle. I had pushed the leaves outboard
|
|
* to open up the space under the crown and taken the inside out with
|
|
* it, so nine lobes hung off a bare hub with nothing joining them.
|
|
* Looking at the reference again: its crown is FULL from the bole
|
|
* outward, and the clear space is underneath, not within. An inner
|
|
* clump on every bough gives them all a common core, and it is
|
|
* guaranteed to work rather than tuned to, because every bough passes
|
|
* through the middle.
|
|
*/
|
|
/**
|
|
* INCLUDING one well inboard, at 0.34.
|
|
*
|
|
* Every clump sat on the outer half of its limb, which makes a
|
|
* crown that is a RING: full at the rim, hollow in the middle, and
|
|
* from a third of the compass you are looking straight through the
|
|
* hole. The turntable is what showed it — one three-quarter view
|
|
* looked fine and two of six were full of daylight.
|
|
*
|
|
* I had this right once and removed it while cutting triangles,
|
|
* which is the cheapest kind of regression to make and the hardest
|
|
* to notice: nothing failed, the model just got worse.
|
|
*/
|
|
for (const along of [0.36, 0.68, 0.95]) {
|
|
const r = clumpR * (0.85 + random() * 0.45)
|
|
leaves.push(clump(r, [
|
|
jitter(random, r * 0.55), len * along + jitter(random, r * 0.3), jitter(random, r * 0.55),
|
|
], 0.1 + jitter(random, 0.13)))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bend, then tilt, then spin — in that order and no other.
|
|
*
|
|
* `bendGeometry` only curves toward +Z, so the tilt has to be a
|
|
* rotation about X: `rotateX(-tilt)` lays the limb over toward -Z while
|
|
* the bend is pushing toward +Z, and the two opposing each other is
|
|
* exactly the oak profile — out near horizontal, then turning up at the
|
|
* end. Tilting about Z instead would have left the bend pointing
|
|
* sideways out of the limb's own plane, and the limb would have curved
|
|
* ACROSS rather than up.
|
|
*
|
|
* The arc angle is `curvature * length`, so a fixed sweep divided by
|
|
* the length gives every limb the same 24 degrees of turn whatever its
|
|
* size — which is what keeps a short top limb looking like the same
|
|
* tree as a long bottom one.
|
|
*/
|
|
const curvature = SWEEP / len
|
|
/**
|
|
* Evenly divided, not the golden angle.
|
|
*
|
|
* The golden angle is the right answer for a great many leaves and the
|
|
* wrong one for nine limbs: it is uniform in the limit, but at this
|
|
* count it left azimuth gaps ranging from 17 to 52 degrees, and the
|
|
* widest gap was where the crown tore into two pieces. An even
|
|
* division gives every gap 40 degrees, and the jitter keeps it from
|
|
* looking like a wheel.
|
|
*/
|
|
const spin = (i / limbCount) * Math.PI * 2 + jitter(random, 0.11)
|
|
for (const group of [local, leaves]) {
|
|
if (group.length === 0) continue
|
|
const merged = mergeColoured(group)
|
|
bendGeometry(merged, curvature)
|
|
merged.rotateX(-tilt)
|
|
merged.rotateY(spin)
|
|
merged.translate(0, ay, 0)
|
|
;(group === local ? wood : crown).push(merged)
|
|
}
|
|
}
|
|
|
|
// The leader: the bole's own top, which is a branch tip like any other
|
|
// and gets its clump for the same reason.
|
|
if (leafiness > 0) {
|
|
// Two, sitting lower. Three clumps perched a third of a radius above
|
|
// the bole's tip put a cap on the crown, and the profile showed it:
|
|
// our top rows measured 0.63 and 0.55 of our widest against the
|
|
// reference's 0.46 and 0.35. An oak tapers to its top; it does not
|
|
// finish in a knob.
|
|
for (let i = 0; i < 2; i += 1) {
|
|
const r = clumpR * (0.8 + random() * 0.4)
|
|
crown.push(clump(r, [
|
|
jitter(random, r * 0.9),
|
|
trunkTop + r * 0.1 + jitter(random, r * 0.35),
|
|
jitter(random, r * 0.9),
|
|
], 0.1 + jitter(random, 0.13)))
|
|
}
|
|
}
|
|
|
|
const boughs = mergeColoured(wood)
|
|
roughenGeometry(boughs, R * 0.09, { salt: 17 })
|
|
const leaves = crown.length > 0 ? mergeColoured(crown) : undefined
|
|
|
|
/**
|
|
* Make `height` mean the height.
|
|
*
|
|
* Everything above is laid out in fractions of H, but the clumps sit
|
|
* PROUD of the tips they hang on, so the finished tree overshot by 8%
|
|
* and the control was quietly lying. Rather than shave the numbers that
|
|
* came off the reference until they happen to land right -- which would
|
|
* have to be redone for every change to the clump size -- the assembly
|
|
* is measured and scaled to fit. Uniformly, so that every ratio the
|
|
* references gave survives it.
|
|
*/
|
|
const all = [trunk, boughs, ...(leaves ? [leaves] : [])]
|
|
let low = Infinity
|
|
let high = -Infinity
|
|
for (const g of all) {
|
|
const position = g.getAttribute('position')
|
|
for (let i = 0; i < position.count; i += 1) {
|
|
const y = position.getY(i)
|
|
if (y < low) low = y
|
|
if (y > high) high = y
|
|
}
|
|
}
|
|
const grown = high - low
|
|
if (grown > 1e-6) {
|
|
const k = H / grown
|
|
for (const g of all) {
|
|
g.scale(k, k, k)
|
|
g.translate(0, -low * k, 0)
|
|
g.computeVertexNormals()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* And make `spread` mean the spread, the same way.
|
|
*
|
|
* A uniform scale fixes the height but cannot fix the width, because the
|
|
* crown's width is not 2x its radius: seven limbs on a golden-angle
|
|
* spiral never put two of them opposite each other, so the bounding box
|
|
* is always narrower than the reach that produced it -- 1.12 against the
|
|
* 1.30 asked for. The shortfall depends on the limb count, so it cannot
|
|
* be a constant either.
|
|
*
|
|
* The stretch is horizontal only and it is applied to the LIMBS AND THE
|
|
* CROWN, never to the bole: stretching the trunk would throw away the one
|
|
* profile in this model that was read off a photograph row by row. It
|
|
* cannot pull anything loose, because every limb's foot sits exactly on
|
|
* the axis and a scale about the axis leaves the axis where it is.
|
|
*/
|
|
const spread = [boughs, ...(leaves ? [leaves] : [])]
|
|
let x0 = Infinity, x1 = -Infinity, z0 = Infinity, z1 = -Infinity
|
|
for (const g of spread) {
|
|
const position = g.getAttribute('position')
|
|
for (let i = 0; i < position.count; i += 1) {
|
|
const x = position.getX(i)
|
|
const z = position.getZ(i)
|
|
if (x < x0) x0 = x
|
|
if (x > x1) x1 = x
|
|
if (z < z0) z0 = z
|
|
if (z > z1) z1 = z
|
|
}
|
|
}
|
|
// The EXTENT, not the radius. Scaling until the furthest tip sat at the
|
|
// crown radius left the tree at 1.10 for 1.30 asked, because the furthest
|
|
// tip has nothing opposite it -- which is the very asymmetry this is here
|
|
// to correct for. The width of a thing is max minus min.
|
|
const widest = Math.max(x1 - x0, z1 - z0)
|
|
if (widest > 1e-6) {
|
|
const kx = (Math.max(0.2, config.spread) * H) / widest
|
|
for (const g of spread) {
|
|
g.scale(kx, 1, kx)
|
|
g.computeVertexNormals()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Break the crown's outline up, or it is a pile of boulders.
|
|
*
|
|
* This is the difference between foliage and rock and it is a
|
|
* SILHOUETTE property, not a colour one. A clump built as a six-sided
|
|
* lathe has a smooth, regular outline, and a smooth outline at this
|
|
* scale reads as stone however green it is -- the first render of this
|
|
* crown looked like a cairn with moss on it. Roughening the merged crown
|
|
* ragged-edges every facet, and because the shift is derived from each
|
|
* corner's own position the surface stays closed and the clumps stay
|
|
* fused into the one mass they were sized to make.
|
|
*
|
|
* It goes AFTER both scalings so the amount is in finished metres.
|
|
*/
|
|
/**
|
|
* The crown is NOT roughened.
|
|
*
|
|
* It was, at 0.17 of a clump radius, on the reasoning that ragged edges
|
|
* read as foliage where smooth ones read as stone. That is true of a
|
|
* silhouette and false of a surface: what it actually did was crease
|
|
* every facet of every clump and turn the mass into crumpled paper.
|
|
* Rounded shapes that overlap already give a ragged OUTLINE — the
|
|
* raggedness has to come from the arrangement, not from the skin.
|
|
*/
|
|
|
|
return {
|
|
trunk: { slot: 'oak' as const, geometry: trunk },
|
|
boughs: { slot: 'oak' as const, geometry: boughs },
|
|
// Explicitly `undefined` when bare, never omitted. The kit resets a
|
|
// part's anchor before it checks whether there is anything to put in
|
|
// it, so a key that is present and undefined empties the crown --
|
|
// where a MISSING key would never be visited and last build's leaves
|
|
// would still be hanging there.
|
|
crown: leaves ? { slot: 'leaf' as const, geometry: leaves } : undefined,
|
|
}
|
|
},
|
|
}, overrides)
|
|
}
|