Runtime assets
Models, textures and animations prepared for use in a scene or application.
78,758 bytes (0.08 MB)
Download runtime assetsThirteen trailing ivy strands, 3 to 7 m long, each leaf its own five-lobed card hung from its stalk.

Drag to orbit after opening. The model starts stationary.
For your engine. The download is a standard glTF 2.0 binary (GLB) with PBR metallic-roughness materials. glTF stores materials, not lighting or tone mapping, so your engine decides how they read. This 3D view tone-maps with Review Neutral, the Khronos PBR Neutral construction with a smaller glare offset (0.015 instead of 0.04), at exposure 0.9. Its Tone mapping control also shows ACES and Linear, for comparison.
Models, textures and animations prepared for use in a scene or application.
78,758 bytes (0.08 MB)
Download runtime assetsModels plus Kiln source, editing metadata, materials and included revisions, so you can reopen and continue editing.
239,276 bytes (0.24 MB)
Download full kiln assets (remixable)Runtime includes the GLB and its Kiln metadata sidecar. The editable ZIP is this final saved revision: model, exact source, editing manifest and preview. Earlier revisions are left out; the parent revision identifier is kept.
Import the editable ZIP into Kiln’s Library, or run node kiln.mjs import asset.zip in an authoring workspace. Use Kiln 1.4.0 or later.
// Meridian Sanctuary planting (plan.md section 5, assets 32-37): columnar cypress, flowering
// shrubs and trailing ivy. PIECE selects which one this revision is. Each plant is one mesh with
// one alpha-masked material, so placements batch, and binds Holodeck's asset.foliage by its
// Mesh_<name> node. Wind weight is UV.y: a plant's body runs v 0 at its foot to 1 at its top
// (the whole column sways a little), each tuft or strand card runs 0 at its attachment to 1 at
// its tip. Cards are emitted on both sides with outward normals, so foliage shades as a
// volume instead of darkening when a card is seen from behind.
const meta = { name: 'Meridian planting' };
const PIECE = 'ivy-long';
function rng(seed) {
let s = (seed >>> 0) || 0x9e3779b9;
return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; };
}
const nrm = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
const add = (a, b, k = 1) => [a[0] + b[0] * k, a[1] + b[1] * k, a[2] + b[2] * k];
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
// ---------------------------------------------------------------- textures (512 x 512 atlases)
// Column 0 (u 0 - 0.25) is an opaque body strip; columns 1 - 3 hold card sprays drawn with their
// attachment at the top of the image (v = 0) and their tip at the bottom (v = 1). Cleared texels
// keep a near-zero alpha and a foliage colour, so filtering never darkens a silhouette.
function bodyStrip(layers, floor) { // floor: the colour cleared texels keep, at alpha 1
return [{ op: 'legacy-v2', spec: { schemaVersion: 2, size: 512, usage: 'albedo', layers } },
{ op: 'mask', shape: { kind: 'rectangle', x: 0, y: 0, width: 128, height: 512 } },
{ op: 'paint', shape: { kind: 'rectangle', x: 128, y: 0, width: 384, height: 512 }, rgba: [...floor, 1] }];
}
// A flame-shaped spray in column c: overlapping leaf blobs in bands that widen then taper.
function spray(c, bands, blob, colours, seed) {
const cx = 128 * c + 64, ops = [];
bands.forEach(([y0, y1, w, n], i) => colours.forEach(([rgba, share], k) => ops.push({ op: 'stamps',
shape: { kind: 'ellipse', x: 0, y: 0, width: blob[0], height: blob[1] }, rgba, count: Math.max(1, Math.round(n * share)),
seed: seed + 31 * i + 7 * k, area: { x: cx - Math.round(w / 2), y: y0, width: w, height: y1 - y0 } })));
return ops;
}
// Cypress (turn 8): a 512 px atlas of 128 px cells (2 px gutters), composed from one procedural
// source per region since a single texture allows only 256 stamps. Column 0 is the opaque core
// strip, dark olive with lighter and darker blotches, over a band of bark at its foot (v 0 -
// 0.05, which the trunk maps). Columns 1 - 3 hold twelve clump cells in four rows by height on
// the tree (row 0 at the foot, so the wind weight grows upward). Each clump is a dense oval of
// needle tufts: dark at its root (the cell's top), deep olive in the middle, and a fringe of
// fine lit tufts round its tip and sides, so the outline is soft rather than serrated; higher
// rows and the third column a little brighter.
async function cypressAtlas() {
const ell = (w, h) => ({ kind: 'ellipse', x: 0, y: 0, width: w, height: h });
const regions = [{ name: 'core', content: { x: 2, y: 2, width: 124, height: 508 }, source: { kind: 'procedural', spec: { schemaVersion: 3, width: 124, height: 508, usage: 'albedo',
background: [52, 66, 32, 255], operations: [
{ op: 'stamps', shape: ell(12, 14), rgba: [78, 94, 42, 255], count: 110, seed: 22, area: { x: 0, y: 30, width: 112, height: 470 } },
{ op: 'stamps', shape: ell(10, 12), rgba: [36, 46, 24, 255], count: 80, seed: 24, area: { x: 0, y: 30, width: 114, height: 470 } },
{ op: 'paint', shape: { kind: 'rectangle', x: 0, y: 0, width: 124, height: 26 }, rgba: [104, 90, 70, 255] },
{ op: 'stamps', shape: { kind: 'rectangle', x: 0, y: 0, width: 3, height: 9 }, rgba: [74, 62, 48, 255], count: 40, seed: 23, area: { x: 0, y: 0, width: 121, height: 17 } }] } } }];
for (let r = 0; r < 4; r++) for (const c of [1, 2, 3]) {
const lift = 5 * r + 5 * (c - 2), seed = 500 + 13 * r + 5 * c;
regions.push({ name: `clump-${r}-${c}`, content: { x: 128 * c + 2, y: 128 * r + 2, width: 124, height: 124 }, source: { kind: 'procedural', spec: {
schemaVersion: 3, width: 124, height: 124, usage: 'albedo', background: [74, 92, 40, 1], operations: [
{ op: 'stamps', shape: ell(12, 14), rgba: [44 + lift, 58 + lift, 28, 255], count: 64, seed, area: { x: 28, y: 6, width: 56, height: 50 } },
{ op: 'stamps', shape: ell(10, 12), rgba: [72 + lift, 90 + lift, 40, 255], count: 92, seed: seed + 1, area: { x: 18, y: 24, width: 78, height: 58 } },
{ op: 'stamps', shape: ell(6, 8), rgba: [108 + lift, 126 + lift, 54, 255], count: 98, seed: seed + 2, area: { x: 12, y: 40, width: 94, height: 74 } }] } } });
}
return textureAtlas({ schemaVersion: 1, width: 512, height: 512, usage: 'albedo', regions });
}
// Shrubs use only the top 30 % of each column (their bodies and tufts map v 0 - 0.3): a mound
// of small leaves strewn with cream flowers, and rounded leaf clusters.
function shrubAtlas() {
const ops = [{ op: 'legacy-v2', spec: { schemaVersion: 2, size: 512, usage: 'albedo', layers: [
{ op: 'noise', colorA: 0x1d3a1c, colorB: 0x4b7238, scale: 30, octaves: 3, seed: 13 }] } },
{ op: 'stamps', shape: { kind: 'ellipse', x: 0, y: 0, width: 6, height: 5 }, rgba: [250, 242, 222, 255], count: 40, seed: 14, area: { x: 2, y: 2, width: 120, height: 88 } },
{ op: 'mask', shape: { kind: 'rectangle', x: 0, y: 0, width: 128, height: 512 } },
{ op: 'paint', shape: { kind: 'rectangle', x: 128, y: 0, width: 384, height: 512 }, rgba: [46, 80, 40, 1] }];
const bands = [[4, 70, 96, 18], [46, 150, 112, 22]];
const colours = [[[50, 86, 42, 255], 0.6], [[96, 134, 66, 255], 0.4]];
// White and cream blossom clusters over the leaves of every spray (the reviews saw no
// flowering shrubs): larger cream heads and smaller white ones.
for (const c of [1, 2, 3]) {
ops.push(...spray(c, bands, [20, 18], colours, 200 * c));
ops.push({ op: 'stamps', shape: { kind: 'ellipse', x: 0, y: 0, width: 12, height: 10 }, rgba: [250, 240, 214, 255], count: 14, seed: 260 * c, area: { x: 128 * c + 14, y: 8, width: 96, height: 140 } },
{ op: 'stamps', shape: { kind: 'ellipse', x: 0, y: 0, width: 7, height: 6 }, rgba: [255, 252, 246, 255], count: 12, seed: 270 * c, area: { x: 128 * c + 12, y: 6, width: 100, height: 144 } });
}
return proceduralTexture({ schemaVersion: 3, width: 512, height: 512, usage: 'albedo', background: [46, 80, 40, 1], operations: ops });
}
// Ivy (turn 8): sixteen 128 px cells, one five-lobed leaf hanging from its stalk in each, with a
// pale midrib and lateral veins, in four rows by place along a strand: row 0 the mature, darker
// leaves of the crown and the strand tops, row 3 the light yellow-green tips (the mean stays near
// sRGB 90, 130, 50). Four hues per row. A card maps one cell, its stalk at the cell's top, so the
// wind weight (UV.y) grows toward a strand's tip.
const IVY_LEAF = [[50, 14], [58, 6], [68, 2], [78, 4], [86, 10], [90, 18], [86, 26], [80, 30], [90, 36], [98, 44], [96, 52], [88, 56], [76, 58],
[72, 66], [66, 78], [58, 90], [50, 100], [42, 90], [34, 78], [28, 66], [24, 58], [12, 56], [4, 52], [2, 44], [10, 36], [20, 30], [14, 26],
[10, 18], [14, 10], [22, 4], [32, 2], [42, 6]]; // in a 100 x 100 box, the stalk joining at (50, 14)
const IVY_HUES = [[[58, 94, 38], [66, 104, 42], [52, 86, 36], [72, 110, 46]], [[76, 116, 46], [86, 126, 50], [70, 108, 44], [94, 132, 54]],
[[102, 140, 56], [112, 148, 60], [96, 134, 52], [120, 156, 64]], [[136, 168, 70], [146, 176, 78], [128, 160, 66], [156, 182, 84]]];
function ivyAtlas() {
const ops = [];
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) {
const x = 128 * c, y = 128 * r, wide = [1.04, 0.94, 1.0, 1.1][c], body = IVY_HUES[r][c];
const pt = ([px, py]) => [Math.round(x + 64 + (px - 50) * 1.04 * wide), Math.round(y + 20 + py * 1.04)];
const vein = [...body.map((v, i) => Math.min(255, v + [44, 40, 26][i])), 255];
ops.push({ op: 'paint', shape: { kind: 'polygon', points: IVY_LEAF.map(pt) }, rgba: [...body, 255] },
{ op: 'paint', shape: { kind: 'line', from: [x + 64, y + 1], to: [x + 64, y + 112], width: 3 }, rgba: vein },
{ op: 'paint', shape: { kind: 'polygon', points: [[20, 62], [50, 30], [80, 62], [80, 65], [50, 33], [20, 65]].map(pt) }, rgba: vein });
}
return proceduralTexture({ schemaVersion: 3, width: 512, height: 512, usage: 'albedo', background: [90, 130, 50, 1], operations: ops });
}
const foliage = (albedo, roughness) => pbrMaterial({ albedo, roughness, metalness: 0, alphaMode: 'mask', alphaCutoff: 0.5, doubleSided: false });
// ---------------------------------------------------------------- geometry
function makeMesh() {
const M = { P: [], N: [], T: [], I: [] };
M.vert = (p, n, uv) => { M.P.push(...p); M.N.push(...n); M.T.push(...uv); return M.P.length / 3 - 1; };
// a card both ways round, all four corners shaded with normal n
M.card = (c, n, uv) => {
const i = c.map((p, k) => M.vert(p, n, uv[k])), j = c.map((p, k) => M.vert(p, n, uv[k]));
M.I.push(i[0], i[1], i[2], i[0], i[2], i[3], j[0], j[2], j[1], j[0], j[3], j[2]);
};
M.geo = () => meshGeo({ positions: M.P, normals: M.N, uvs: M.T, indices: M.I });
return M;
}
// A lathed body around +Y with a lumpy radius: rings from y0 to H, the strip u 0 - 0.25 around,
// v 0 at the foot to vTop at the top (1 for a column that sways; less for a low mound, which
// also keeps its texture from squashing).
function body(M, H, y0, radius, rings, segs, lump, R, vTop = 1) {
const ph = Array.from({ length: 4 }, () => R() * 6.283);
const bump = (th, t) => 1 + lump * (0.5 * Math.sin(3 * th + ph[0] + 9 * t) + 0.3 * Math.sin(5 * th + ph[1] - 13 * t) + 0.2 * Math.sin(7 * th + ph[2] + 21 * t));
const base = M.P.length / 3;
for (let i = 0; i <= rings; i++) {
const t = i / rings, y = y0 + t * (H - y0), r = radius(t);
for (let j = 0; j <= segs; j++) {
const th = (j / segs) * 2 * Math.PI, rr = r * bump(th, t);
M.vert([rr * Math.cos(th), y, rr * Math.sin(th)], nrm([Math.cos(th), 0.25 + 0.6 * t * t, Math.sin(th)]), [(0.25 * j) / segs, t * vTop]);
}
}
for (let i = 0; i < rings; i++) for (let j = 0; j < segs; j++) {
const a = base + i * (segs + 1) + j, b = a + 1, c = a + segs + 1, d = c + 1;
M.I.push(a, c, b, b, c, d);
}
return bump;
}
// Crossed tuft cards rooted just inside the body, pointing out and up: n of them, placed by
// area (rejection sampling on the radius), each picking one of the spray columns.
function tufts(M, H, y0, radius, bump, n, R, o) {
for (let k = 0; k < n;) {
const t = o.t0 + R() * (o.t1 - o.t0), rad = radius(t);
if (R() * o.rmax > rad) continue;
k++;
const th = R() * 2 * Math.PI, y = y0 + t * (H - y0), r = rad * bump(th, t), out = [Math.cos(th), 0, Math.sin(th)];
const elev = o.elev[0] + R() * (o.elev[1] - o.elev[0]) + o.elevTop * t;
const dir = nrm([out[0] * Math.cos(elev), Math.sin(elev), out[2] * Math.cos(elev)]);
const side1 = [-Math.sin(th), 0, Math.cos(th)], side2 = nrm(cross(dir, side1));
const L = (o.len[0] + R() * (o.len[1] - o.len[0])) * (0.55 + 0.45 * (rad / o.rmax)), W = L * (o.aspect[0] + R() * (o.aspect[1] - o.aspect[0]));
const root = add([r * out[0], y, r * out[2]], out, -o.sink), tip = add(root, dir, L);
const col = 1 + Math.floor(R() * 3), u0 = 0.25 * col + 0.01, u1 = u0 + 0.23, n2 = nrm(add(out, [0, 1, 0], 0.4)), vt = o.vMax ?? 1;
for (const side of o.cross === false ? [side1] : [side1, side2]) {
M.card([add(root, side, -W / 2), add(root, side, W / 2), add(tip, side, W * 0.45), add(tip, side, -W * 0.45)], n2,
[[u0, 0], [u1, 0], [u1, vt], [u0, vt]]);
}
}
}
// Italian cypress (turn 8, after the reviews of revision 10): a slender column, height about six
// times its width, on a short visible trunk; widest a third of the way up, tapering to a blunt
// point, its axis wandering a little. A dark core carries several hundred small crossed clump
// cards rooted in it and set by area, pointing out and up, so the outline is soft and irregular.
// Their shading normals are spherical (out from the axis, a little up) so the column shades as
// one volume: dark in its shade, lit olive on the sun side, with bright tips.
async function cypress(H, W, n, seed, trunk) {
const R = rng(seed), M = makeMesh(), rc = W / 2 - 0.2, y0 = trunk - 0.12;
const prof = (t) => (t < 0.32 ? 0.58 + 0.42 * Math.sin((Math.PI / 2) * (t / 0.32)) : Math.pow((1 - t) / 0.68, 0.75));
const radius = (t) => Math.max(0.02, rc * prof(t));
const ph = [R() * 6.283, R() * 6.283, R() * 6.283, R() * 6.283, R() * 6.283];
const axis = (t) => [0.05 * t * Math.sin(5.5 * t + ph[0]), 0.04 * t * Math.sin(4.3 * t + ph[1])];
const bump = (th, t) => 1 + 0.09 * (0.5 * Math.sin(3 * th + ph[2] + 11 * t) + 0.3 * Math.sin(5 * th + ph[3] - 17 * t) + 0.2 * Math.sin(7 * th + ph[4] + 23 * t));
// the trunk: 8 sides tapering from 0.12 to 0.085 m, bark at v 0 - 0.05 (it does not sway)
const tb = M.P.length / 3;
for (let i = 0; i <= 1; i++) for (let j = 0; j <= 8; j++) {
const th = (j / 8) * 2 * Math.PI, r = i ? 0.085 : 0.12;
M.vert([r * Math.cos(th), i ? trunk : 0, r * Math.sin(th)], [Math.cos(th), 0, Math.sin(th)], [(0.25 * j) / 8, i ? 0.05 : 0.004]);
}
for (let j = 0; j < 8; j++) M.I.push(tb + j, tb + j + 9, tb + j + 1, tb + j + 1, tb + j + 9, tb + j + 10);
// the core: a ring every 0.3 m, 18 sides; strip u 0 - 0.25 around, v 0.07 at its foot to 1 at the top
const rings = Math.round((H - y0) / 0.3), segs = 18, cb = M.P.length / 3;
for (let i = 0; i <= rings; i++) {
const t = i / rings, y = y0 + t * (H - y0), r = radius(t), [ax, az] = axis(t);
for (let j = 0; j <= segs; j++) {
const th = (j / segs) * 2 * Math.PI, rr = r * bump(th, t);
M.vert([ax + rr * Math.cos(th), y, az + rr * Math.sin(th)], nrm([Math.cos(th), 0.2 + 0.5 * t * t, Math.sin(th)]), [(0.25 * j) / segs, 0.07 + 0.93 * t]);
}
}
for (let i = 0; i < rings; i++) for (let j = 0; j < segs; j++) {
const a = cb + i * (segs + 1) + j, c = a + segs + 1;
M.I.push(a, c, a + 1, a + 1, c, c + 1);
}
// the clumps, by area over the core: rooted 0.1 m inside it, longer where the column is wide
for (let k = 0; k < n;) {
const t = 0.015 + R() * 0.975, rad = radius(t);
if (R() * rc > rad) continue;
k++;
const th = R() * 2 * Math.PI, [ax, az] = axis(t), y = y0 + t * (H - y0), out = [Math.cos(th), 0, Math.sin(th)];
const r = rad * bump(th, t), elev = 0.45 + R() * 0.5 + 0.55 * t * t;
const dir = nrm([out[0] * Math.cos(elev), Math.sin(elev), out[2] * Math.cos(elev)]);
const tan = [-Math.sin(th), 0, Math.cos(th)], spin = R() * Math.PI; // the crossed pair turned at random about its axis
const side1 = add(tan.map((v) => v * Math.cos(spin)), cross(dir, tan), Math.sin(spin)), side2 = nrm(cross(dir, side1));
const L = (0.3 + 0.24 * R()) * (0.55 + 0.45 * Math.min(1, rad / (0.7 * rc))), Wc = L * (0.8 + 0.25 * R());
const root = [ax + (r - 0.1) * out[0], y - 0.04, az + (r - 0.1) * out[2]], tip = add(root, dir, L);
const row = Math.min(3, Math.floor(t * 4)), col = 1 + Math.floor(R() * 3);
const u0 = 0.25 * col + 0.006, u1 = u0 + 0.238, v0 = 0.25 * row + 0.006, v1 = v0 + 0.238;
const ns = nrm([out[0], 0.3 + 0.4 * t, out[2]]);
for (const side of [side1, side2]) {
M.card([add(root, side, -Wc / 2), add(root, side, Wc / 2), add(tip, side, Wc / 2), add(tip, side, -Wc / 2)], ns, [[u0, v0], [u1, v0], [u1, v1], [u0, v1]]);
}
}
return [['cypress', M.geo(), foliage(await cypressAtlas(), 0.85)]];
}
// Flowering mound: widest near the ground, rounded on top.
function shrub(H, R0, n, seed) {
const R = rng(seed), M = makeMesh(), radius = (t) => R0 * Math.max(0.05, Math.sqrt(Math.max(0, 1 - Math.pow(t, 2.2))) * (0.82 + 0.3 * Math.sin(Math.PI * Math.min(1, t * 1.6))));
const bump = body(M, H, 0, radius, 7, 12, 0.14, R, 0.18);
tufts(M, H, 0, radius, bump, n, R, { t0: 0.06, t1: 0.95, rmax: R0 * 1.1, elev: [0.2, 0.8], elevTop: 0.6, len: [0.25 * H + 0.18, 0.4 * H + 0.24],
aspect: [1.0, 1.4], sink: 0.1, cross: false, vMax: 0.3 });
return [['shrub', M.geo(), foliage(shrubAtlas(), 0.85)]];
}
// Trailing ivy (turn 8, after the reviews of revision 10), in the ledge's frame: the edge runs
// along x at y = 0, z = 0, the ledge top lies toward -Z and the drop toward +Z. Strands in two or
// three loose groups root on the ledge, run over the edge and hang, bulging out a little and
// drifting outward toward the ends, as ivy trails down pier edges. Every leaf is its own small
// card hung from its stalk, turned and tipped out from the wall at random, on alternate sides of
// its strand: close-set, large and dark near the top, sparser, smaller and lighter toward the
// tapering tip. A crown of leaves over each group and a ragged line along the edge between them
// make the top densest. Shading normals are bent out from the wall (and a little up), toward
// the warm haze and the court rather than the blue zenith, so curtains in shade stay green.
function ivy(span, strands, lenRange, seed, o) {
const R = rng(seed), M = makeMesh();
const leaf = (p, size, row, lean) => { // a card from its stalk at p, the blade below it
const roll = (R() - 0.5) * 0.9, twist = (R() - 0.5) * 1.2;
const d = nrm([Math.sin(roll) * Math.cos(lean), -Math.cos(lean) * Math.cos(roll), Math.sin(lean)]);
const a0 = nrm(cross(d, [0, 0, 1])), a = add(a0.map((v) => v * Math.cos(twist)), cross(d, a0), Math.sin(twist));
const half = add([0, 0, 0], a, size / 2), foot = add(p, d, size), col = Math.floor(R() * 4);
const u0 = 0.25 * col + 0.006, u1 = u0 + 0.238, v0 = 0.25 * row + 0.006, v1 = v0 + 0.238;
const n = nrm([0.25 * (R() - 0.5), 0.3 + 0.2 * R(), 1]);
M.card([add(p, half, -1), add(p, half, 1), add(foot, half, 1), add(foot, half, -1)], n, [[u1, v0], [u0, v0], [u0, v1], [u1, v1]]);
};
const groups = Array.from({ length: 2 + Math.floor(R() * 2) }, () => (R() - 0.5) * span * 0.8);
const raw = Array.from({ length: strands }, (_, k) => groups[k % groups.length] + (R() - 0.5) * span * 0.32);
const mid = (Math.min(...raw) + Math.max(...raw)) / 2; // centre the curtain on the piece
groups.forEach((g, i) => { groups[i] = g - mid; });
const roots = raw.map((x) => Math.max(-span / 2, Math.min(span / 2, x - mid))).sort((a, b) => a - b);
for (const x0 of roots) {
const L = lenRange[0] + Math.pow(R(), 0.7) * (lenRange[1] - lenRange[0]), bulge = 0.08 + R() * 0.22;
const out = Math.sign(x0 || 1) * Math.pow(Math.abs(x0) / (span / 2), 2) * (0.3 + R() * 0.6) + (R() - 0.5) * 0.3; // drift at the tip
const at = (s) => (s < 0.2 ? [x0, 0.04, -0.2 + s] // along the ledge top to the edge, then down the face
: [x0 + out * ((s - 0.2) / (L - 0.2)) ** 1.6, -(s - 0.2), 0.05 + bulge * (1 - Math.exp(-(s - 0.2) / 0.55)) - 0.04 * ((s - 0.2) / (L - 0.2))]);
let side = R() < 0.5 ? -1 : 1;
for (let s = 0.04 + 0.06 * R(); s < L; s += 0.045 + 0.12 * (s / L) ** 1.2 + 0.03 * R()) {
const f = s / L, p = add(at(s), [side * (0.03 + 0.07 * R()) * (1 - 0.5 * f), 0, 0.015]);
const row = Math.max(0, Math.min(3, Math.floor(4 * f ** 0.85 + 0.6 * (R() - 0.5))));
leaf(p, o.leaf * (1 - 0.48 * f) * (0.85 + 0.3 * R()), row, s < 0.2 ? 1.1 + 0.4 * R() : 0.25 + 0.55 * R()); // flatter on the ledge top
side = R() < 0.82 ? -side : side; // mostly alternate, sometimes two on one side
}
}
for (const g of groups) for (let k = 0; k < o.crown; k++) { // the crown over each group, on the ledge top and over the edge
const x = g + (R() + R() - 1) * span * 0.2, z = -0.26 + 0.34 * R(), onTop = z < -0.02;
leaf([x, onTop ? 0.06 + 0.1 * R() : 0.08 * R() - 0.06, z], o.leaf * (0.9 + 0.35 * R()), R() < 0.7 ? 0 : 1, onTop ? 1.0 + 0.5 * R() : 0.4 + 0.5 * R());
}
for (let x = -span / 2; x < span / 2; x += 0.12 + 0.2 * R()) { // a ragged line along the edge between the groups
if (R() < 0.45) leaf([x, 0.05 * R(), -0.05 + 0.08 * R()], o.leaf * (0.75 + 0.3 * R()), R() < 0.6 ? 0 : 1, 0.6 + 0.6 * R());
}
return [['ivy', M.geo(), foliage(ivyAtlas(), 0.85)]];
}
const PIECES = {
'cypress-tall': () => cypress(10, 1.6, 560, 1, 0.6), // 6.3 : 1
'cypress-mid': () => cypress(7.2, 1.24, 400, 2, 0.5), // 5.8 : 1
'shrub-a': () => shrub(1.2, 0.8, 150, 3),
'shrub-b': () => shrub(0.6, 0.5, 90, 4),
'ivy-long': () => ivy(3.4, 13, [3.0, 7.0], 5, { leaf: 0.26, crown: 40 }),
'ivy-short': () => ivy(2.6, 9, [1.2, 3.0], 6, { leaf: 0.24, crown: 30 }),
};
// Review only (never saved): a cypress pair, shrubs and both ivies on a stand-in ledge.
async function previewPlanting() {
const out = [], stone = pbrMaterial({ albedo: proceduralTexture({ schemaVersion: 2, size: 64, usage: 'albedo', layers: [{ op: 'noise', colorA: 0xe7dfd0, colorB: 0xf8f4ec, scale: 4, octaves: 2, seed: 5 }] }), roughness: 0.7, metalness: 0 });
const B = makeMesh(), box = (lo, hi) => { // a plain box for the stand-ins
const f = [[[hi[0], lo[1], hi[2]], [hi[0], lo[1], lo[2]], [hi[0], hi[1], lo[2]], [hi[0], hi[1], hi[2]], [1, 0, 0]], [[lo[0], lo[1], lo[2]], [lo[0], lo[1], hi[2]], [lo[0], hi[1], hi[2]], [lo[0], hi[1], lo[2]], [-1, 0, 0]],
[[lo[0], hi[1], hi[2]], [hi[0], hi[1], hi[2]], [hi[0], hi[1], lo[2]], [lo[0], hi[1], lo[2]], [0, 1, 0]], [[lo[0], lo[1], hi[2]], [hi[0], lo[1], hi[2]], [hi[0], hi[1], hi[2]], [lo[0], hi[1], hi[2]], [0, 0, 1]]];
for (const [a, b, c, d, n] of f) { const i = [a, b, c, d].map((p, k) => B.vert(p, n, [k % 2, k > 1 ? 1 : 0])); B.I.push(i[0], i[1], i[2], i[0], i[2], i[3]); }
};
box([-4, 0, -1], [4, 8, 0]);
out.push(['stand_wall', B.geo(), stone]);
const place = (parts, d) => { for (const [name, geo, mat] of parts) { geo.translate(d[0], d[1], d[2]); out.push([`${name}_${out.length}`, geo, mat]); } };
place(PIECES['ivy-long'](), [-2, 8, 0]);
place(PIECES['ivy-short'](), [2, 8, 0]);
place(await PIECES['cypress-tall'](), [-6, 0, 3]);
place(await PIECES['cypress-mid'](), [6, 0, 3]);
place(PIECES['shrub-a'](), [-2, 0, 3]);
place(PIECES['shrub-b'](), [1.5, 0, 3]);
return out;
}
async function build() {
const root = createRoot(PIECE);
const parts = await (PIECE === 'preview-planting' ? previewPlanting() : PIECES[PIECE]());
for (const [name, geo, mat] of parts) createPart(name, geo, mat, { parent: root });
return root;
}Scroll code horizontally
Asset content is dedicated CC0-1.0. Kiln and Holodeck software keep their own licences.