このシミュレーションは、1年を12の刻みに分け、「影をますにためる → 芽の日当たりを測る → 養分を配り、暗い枝を落とす → 芽が節を作る → 測る」をくり返している。立体の描画・影・視点はthree.jsが受け持ち、このファイルが計算するのは、枝の1区切り(節)ごとの位置と向きと太さである。描くときは、その結果を円柱と葉の板の行列にしてthree.jsに渡す。
長さはメートル、1年は12刻み
長さの単位はメートルで、時間は1年を12刻み(STEPS)に分けて進める。画面の速さを変えても刻みの長さは変わらないので、同じ場面からは必ず同じ木が育つ。新しい枝は9刻みかけて伸びきるように描く(GROW)。
定数と単位(src/sims/plant-growth.ts の 31〜60 行) // 長さは m、時間は年。1 年 = 12 刻み(STEPS)。新しい枝は 9 刻みかけて伸びきるように描く(GROW) const STEPS = 12 , GROW = 9 ;const SH_A = 0.1 , SH_B = 1.5 , QMAX = 6 ; // 影の四角すい: 自分のますの影 a、1 段下るごとに 1/b、6 段まで const DIRECT = 0.5 ; // 日の光のうち、太陽から直接来る割合(残りは空全体から) const ETA = 0.03 ; // 上へ向かう性質(負の重力屈性)の重み const G0 = 0.15 ; // 影の勾配がこれより弱いときは、曲がり方も弱くする const KA = 2 ; // 空がふさがれた偏りを、曲がる向きにどれだけ効かせるか const K_SAT = 0.25 ; // 光合成が頭打ちになる日当たりの目安 const KL = 0.3 ; // 大きな木の冠の中で光が弱まる割合(1 m あたり。葉面積密度 0.6 m²/m³ × 吸光係数 0.5) const STAND_R = 12 , STAND_FILL = 0.55 ; // 林はこの半径の先も続くとみなす。その先は、冠が占める割合 0.55 の一様な茂みとする const SHED_T = 0.09 , SHED_AGE = 3 ; // 受ける光 ÷ 節の数 がこの値より小さい横枝を落とす。見るのは、伸びてから 3 年たった横枝だけ const CAP = [8000 , 8000 , 10000 ]; // 節の数の上限(画質 0・1・2)。場面の木は 30 年で 7,600 節ほどなので、どの画質でも同じ木が育つ const MAXTREES = 8 ;interface Look { l0: number; cell: number; leafL: number; leafW: number; d0: number; rmin: number; alpha: number; nmax: number; leafYears: number; budLife: number; half: number; top: number; shed: number; seed: number; } // 木の場面と、葉の並びを近くで見る場面で、大きさを変える // l0 節の基準の長さ・cell 影のます・leafL と leafW 葉の長さと幅・d0 葉 1 枚が支える管の直径・rmin 描くときの最も細い枝の半径・ // alpha 根元の養分の係数 α・nmax 1 つの芽が 1 年に作る節の上限・leafYears 葉が残る年数・budLife 脇芽が眠ったまま生きる年数・ // half と top 計算する範囲(水平の半分の幅と高さ)・shed 枝を落とす境目・seed 種の養分 const TREE: Look = { l0: 0.15 , cell: 0.35 , leafL: 0.2 , leafW: 0.11 , d0: 0.004 , rmin: 0.006 , alpha: 2.5 , nmax: 4 , leafYears: 2 , budLife: 4 , half: 10 , top: 30 , shed: SHED_T, seed: 2 };const SHOOT: Look = { l0: 0.018 , cell: 0.03 , leafL: 0.085 , leafW: 0.04 , d0: 0.0012 , rmin: 0.0022 , alpha: 30 , nmax: 21 , leafYears: 99 , budLife: 99 , half: 0.6 , top: 0.9 , shed: 0 , seed: 0 };const DEG = Math.PI / 180 ;// 空の 7 つの向き(高さ 35° の 6 方向と真上) const SKY: number[][] = [];for (let k = 0 ; k < 6 ; k++) { const az = (k / 6 ) * Math.PI * 2 , el = 35 * DEG; SKY.push([Math.cos(el) * Math.cos(az), Math.sin(el), Math.cos(el) * Math.sin(az)]); }SKY.push([0 , 1 , 0 ]); const SKY_Y = (6 * Math.sin(35 * DEG) + 1 ) / 7 ; // 空の 7 つの向きを平均した向き(真上)の長さ const sunFrom = (elev: number, az: number): [number, number, number] => [Math.cos(elev * DEG) * Math.sin(az * DEG), Math.sin(elev * DEG), Math.cos(elev * DEG) * Math.cos(az * DEG)];
SH_A・SH_B・QMAXは本文のa 、b と、影を落とす段数である。Lookは場面ごとの大きさで、木の場面では節の基準の長さl0を15 cm、影のますcellを35 cmにした。葉の並びの場面だけは、若い茎を近くで見るために、すべてを小さくした別の組(SHOOT)を使う。CAPは節の数の上限で、画質「高」では1万、ほかでは8,000にした。場面の木は30年で7,600節ほどなので、どの画質でも同じ木が育つ。
影をますにためる
castShadowsは、葉のある節から下へ6段、四角すいの形にますへ影を足す。段を1つ下るごとに、四角すいの中心をsx・szますだけ横へずらす。これが、影を光の来る向きへ傾ける仕組みで、本文の図の右側にあたる。
光と影の格子(src/sims/plant-growth.ts の 108〜203 行) // 光の来る向き: 太陽の向きと真上を、直射と空の光の割合で混ぜる function lightDir(F: Forest, out: number[]) { const s = F.sun, x = DIRECT * s[0 ], y = DIRECT * s[1 ] + (1 - DIRECT), z = DIRECT * s[2 ], n = Math.hypot(x, y, z); out[0 ] = x / n; out[1 ] = y / n; out[2 ] = z / n; } const L3 = [0 , 1 , 0 ];// 葉のある節(from 〜 to 番)が、光の来る向きの反対側へ四角すいの影を落とす function castShadows(F: Forest, from : number, to: number) { const { nx, ny, nz, grid } = F, c = F.look.cell; lightDir(F, L3); const sx = -L3[0 ] / L3[1 ], sz = -L3[2 ] / L3[1 ]; // 1 段下るごとに横へずれるます for (let i = from ; i < to; i++) { if (!F.leaf[i]) continue ; const ex = F.px[i] + F.dx[i] * F.len[i], ey = F.py[i] + F.dy[i] * F.len[i], ez = F.pz[i] + F.dz[i] * F.len[i]; const I = Math.floor((ex - F.ox) / c), J = Math.floor(ey / c), K = Math.floor((ez - F.oz) / c); let w = SH_A; for (let q = 0 ; q <= QMAX && J - q >= 0 ; q++, w /= SH_B) { const j = J - q; if (j >= ny) continue ; const ci = Math.round(I + q * sx), ck = Math.round(K + q * sz); const i0 = Math.max(0 , ci - q), i1 = Math.min(nx - 1 , ci + q), k0 = Math.max(0 , ck - q), k1 = Math.min(nz - 1 , ck + q); for (let k = k0; k <= k1; k++) { const row = (j * nz + k) * nx; for (let ii = i0; ii <= i1; ii++) grid[row + ii] += w; } } } } // 点 (x, y, z) のますの影。地面の下と建物の中は濃い影にして、枝が入らないようにする function shadeAt(F: Forest, x: number, y: number, z: number) { const c = F.look.cell, i = Math.floor((x - F.ox) / c), j = Math.floor(y / c), k = Math.floor((z - F.oz) / c); if (j < 0 ) return 9 ; if (i < 0 || k < 0 || i >= F.nx || k >= F.nz || j >= F.ny) return 0 ; const n = (j * F.nz + k) * F.nx + i; return F.grid[n] + (F.wall[n] ? 2 : 0 ); } // 点から向き d へ進む光が、建物と大きな木の冠を通り抜けて届く割合 function reach(F: Forest, x: number, y: number, z: number, d: number[]) { for (const b of F.boxes) { // x・y・z の向きごとに、線が箱の厚みの中にある区間を求めて重ねる。重なりが残れば線は箱を通る let t0 = 0 , t1 = 1e9 ; const slab = (o: number, dd: number, lo: number, hi: number) => { if (Math.abs(dd) < 1e-9 ) { if (o < lo || o > hi) t1 = -1 ; return ; } const ta = (lo - o) / dd, tb = (hi - o) / dd; t0 = Math.max(t0, Math.min(ta, tb)); t1 = Math.min(t1, Math.max(ta, tb)); }; slab(x, d[0 ], b.x0, b.x1); slab(y, d[1 ], b.y0, b.y1); slab(z, d[2 ], b.z0, b.z1); if (t1 > t0) return 0 ; } let T = 1 , y0 = Infinity, y1 = -Infinity; for (const cr of F.crowns) { y0 = Math.min(y0, cr.y0); y1 = Math.max(y1, cr.y1); if (!cr.alive) continue ; const cy = (cr.y0 + cr.y1) / 2 , ry = (cr.y1 - cr.y0) / 2 ; const ox = (x - cr.x) / cr.r, oy = (y - cy) / ry, oz = (z - cr.z) / cr.r; const ddx = d[0 ] / cr.r, ddy = d[1 ] / ry, ddz = d[2 ] / cr.r; const A = ddx * ddx + ddy * ddy + ddz * ddz, B = ox * ddx + oy * ddy + oz * ddz, C = ox * ox + oy * oy + oz * oz - 1 ; const D = B * B - A * C; if (D <= 0 ) continue ; const sq = Math.sqrt(D), ta = Math.max(0 , (-B - sq) / A), tb = (-B + sq) / A; if (tb > ta) T *= Math.exp(-KL * (tb - ta)); } // 林の外側に続く茂み: 光が冠の高さの層を、林の中心から STAND_R より外で通る長さ if (F.crowns.length && d[1 ] > 1e-3 ) { const ta = Math.max(0 , (y0 - y) / d[1 ]), tb = (y1 - y) / d[1 ]; if (tb > ta) { const h = Math.hypot(d[0 ], d[2 ]), rx = x, rz = z; // 水平に進む向きで、半径 STAND_R の円を出る点 const b = (rx * d[0 ] + rz * d[2 ]) / Math.max(h * h, 1e-9 ), c = (rx * rx + rz * rz - STAND_R * STAND_R) / Math.max(h * h, 1e-9 ); const tOut = h > 1e-6 ? -b + Math.sqrt(Math.max(0 , b * b - c)) : Infinity; const L = Math.max(0 , tb - Math.max(ta, tOut)); if (L > 0 ) T *= Math.exp(-KL * STAND_FILL * L); } } return T; } // 建物と大きな木を通して届く光(0〜1)。out には、空のどちら側がふさがれているかの偏り // (届いた光で重みをつけた向きの和 − どの向きも同じだけ弱まったときの和)を入れる。開けた所では 0 function skyLight(F: Forest, x: number, y: number, z: number, out: number[]) { out[0 ] = 0 ; out[1 ] = 0 ; out[2 ] = 0 ; if (!F.boxes.length && !F.crowns.length) return 1 ; const ts = reach(F, x, y, z, F.sun) * DIRECT; let T = ts, lx = ts * F.sun[0 ], ly = ts * F.sun[1 ], lz = ts * F.sun[2 ]; for (const d of SKY) { const t = (reach(F, x, y, z, d) * (1 - DIRECT)) / SKY.length; T += t; lx += t * d[0 ]; ly += t * d[1 ]; lz += t * d[2 ]; } out[0 ] = lx - T * DIRECT * F.sun[0 ]; out[1 ] = ly - T * (DIRECT * F.sun[1 ] + (1 - DIRECT) * SKY_Y); out[2 ] = lz - T * DIRECT * F.sun[2 ]; return T; } // 芽の日当たり Q(0〜1)。影の格子の分と、建物や大きな木の分を掛ける function budLight(F: Forest, x: number, y: number, z: number, out: number[]) { const q = Math.min(1 , Math.max(0 , 1 + SH_A - shadeAt(F, x, y, z))); return q * skyLight(F, x, y, z, out); } const O3 = [0 , 0 , 0 ];// 頂芽と、眠っている脇芽の日当たりを測る(from 〜 to 番の節) function measure(F: Forest, from : number, to: number) { for (let i = from ; i < Math.min(to, F.m); i++) { const ex = F.px[i] + F.dx[i] * F.len[i], ey = F.py[i] + F.dy[i] * F.len[i], ez = F.pz[i] + F.dz[i] * F.len[i]; F.tq[i] = 0 ; F.bq[i] = 0 ; if (F.tip[i]) { F.tq[i] = budLight(F, ex, ey, ez, O3); F.lx[i] = O3[0 ]; F.ly[i] = O3[1 ]; F.lz[i] = O3[2 ]; } if (F.bud[i]) { F.bq[i] = budLight(F, ex + 0.1 * F.rx[i], ey + 0.1 * F.ry[i], ez + 0.1 * F.rz[i], O3); if (!F.tip[i]) { F.lx[i] = O3[0 ]; F.ly[i] = O3[1 ]; F.lz[i] = O3[2 ]; } } } }
芽の日当たりはbudLightで、本文の式Q = max ( 1 − s + a , 0 ) をそのまま計算し、建物や林の木でさえぎられる分を掛ける。建物と林の大きな木は、ますではなく線で調べる。reachは、芽から向きdへ引いた線が箱(建物)に当たるかを、x・y・zの3つの向きで線が箱に入る区間と出る区間を重ねて確かめる。楕円体(林の木の冠)は、座標を冠の大きさで割って球に直し、線と球の交わりの式から、冠の中を通る長さを出す。skyLightはこれを太陽と空の7つの向きで行い、届いた光の合計と、空のどちら側がふさがれているかの偏りを返す。
根元へ集めて、枝先へ配る
節はすべて、親が子より前に来る順で配列に入っている。そのためallocateは、配列を後ろから前へ1回たどるだけで、枝先の日当たりを根元へ足し上げられる。続けて前から後ろへ1回たどると、根元の養分を枝先へ配れる。
養分の配り方(src/sims/plant-growth.ts の 205〜251 行) // Borchert と Honda の方法。1. 先から根元へ、日当たりを足し上げる 2. 根元から先へ、養分を配る function allocate(F: Forest, g: Gp) { const m = F.m, lam = g.lambda; const P = (q: number) => (q > 0 ? (q * (1 + K_SAT)) / (q + K_SAT) : 0 ); for (let i = 0 ; i < m; i++) { F.qm[i] = P(F.tq[i]); F.ql[i] = P(F.bq[i]); F.cnt[i] = 1 ; } for (let i = m - 1 ; i > 0 ; i--) { const p = F.par[i]; if (p < 0 ) continue ; const q = F.qm[i] + F.ql[i]; if (F.lat[i]) F.ql[p] += q; else F.qm[p] += q; F.cnt[p] += F.cnt[i]; } for (let i = 0 ; i < m; i++) { const p = F.par[i]; // 根元: v = α ΣP(Q)。苗の最初の 3 年は種の養分を足す const vin = p < 0 ? F.look.alpha * (F.qm[i] + F.ql[i]) + (F.step - F.born[i] < 3 * STEPS + GROW ? F.look.seed : 0 ) : F.lat[i] ? F.vl[p] : F.vm[p]; const den = lam * F.qm[i] + (1 - lam) * F.ql[i]; F.vm[i] = den > 0 ? (vin * lam * F.qm[i]) / den : 0 ; // 主軸(頂芽の側)へ F.vl[i] = den > 0 ? vin - F.vm[i] : 0 ; // 横枝(脇芽の側)へ } } // 受ける光 ÷ 節の数 が小さい横枝(伸びてから 3 年以上たったもの)を落とす。落とした数を返す function shed(F: Forest) { if (F.look.shed <= 0 ) return 0 ; let n = 0 ; for (let i = 0 ; i < F.m; i++) { F.kill[i] = 0 ; const p = F.par[i]; if (p >= 0 && F.kill[p]) { F.kill[i] = 1 ; continue ; } if (p >= 0 && F.lat[i] && F.step - F.born[i] > STEPS * SHED_AGE && (F.qm[i] + F.ql[i]) / F.cnt[i] < F.look.shed) { F.kill[i] = 1 ; n++; } } if (n) compact(F); return n; } // kill の付いた節を消して詰める(親が子より前にある並びは保つ) function compact(F: Forest) { const map = new Int32Array(F.m); let j = 0 ; const arrs = [F.tree, F.lat, F.px, F.py, F.pz, F.dx, F.dy, F.dz, F.len, F.rx, F.ry, F.rz, F.born, F.tip, F.bud, F.budAge, F.rt, F.pipes, F.leaf, F.vm, F.vl, F.tq, F.bq, F.lx, F.ly, F.lz]; for (let i = 0 ; i < F.m; i++) { if (F.kill[i]) { map[i] = -1 ; continue ; } map[i] = j; if (j !== i) for (const a of arrs) a[j] = a[i]; F.par[j] = F.par[i] >= 0 ? map[F.par[i]] : -1 ; j++; } for (const t of F.trees) t.root = t.root >= 0 ? map[t.root] : -1 ; F.m = j; }
P(q)は日当たりを養分の元にするときの換算で、日当たりが強くなると頭打ちになる。葉の光合成が強い光で飽和することを、簡単な分数の形で表した。主軸と横枝への分け方は、本文のv m とv l の式のとおりである。shedは、伸びてから3年たった横枝ごとに「受ける光 ÷ 節の数」を調べ、0.09より小さい枝とその先を消す。消したあとはcompactで配列を詰め、親が子より前という順を保つ。
芽が節を作り、向きを決める
addShootは、養分vを受け取った芽から、整数部分nの数だけ節を作る。1年に作る節は4つ(nmax)まで、1節の長さはℓ 0 ⋅ v / n で、1.5倍を上限にした。節の数が上限に届いているあいだは、growが芽を使わずに残し、枝が落ちて空きができた年に伸ばす。
芽が伸びる(src/sims/plant-growth.ts の 253〜328 行) // 芽(from 番の節の先)が、養分 v で新しい枝を伸ばす。lateral なら脇芽から、そうでなければ頂芽から function addShoot(F: Forest, g: Gp, from : number, v: number, lateral: boolean, tr: Tree, limit: number) { const look = F.look, c = look.cell; const n = Math.min(look.nmax, Math.floor(v)); if (n < 1 ) return ; const l = Math.min(1.5 , v / n) * look.l0; // 1 節の長さ ℓ0·v/n(1.5 倍まで) let x = F.px[from ] + F.dx[from ] * F.len[from ], y = F.py[from ] + F.dy[from ] * F.len[from ], z = F.pz[from ] + F.dz[from ] * F.len[from ]; let dx: number, dy: number, dz: number, rx = F.rx[from ], ry = F.ry[from ], rz = F.rz[from ]; const cb = Math.cos(g.beta), sb = Math.sin(g.beta); if (lateral) { // 脇芽の向き: 茎から枝の角度 β だけ開く dx = cb * F.dx[from ] + sb * rx; dy = cb * F.dy[from ] + sb * ry; dz = cb * F.dz[from ] + sb * rz; rx = F.dx[from ]; ry = F.dy[from ]; rz = F.dz[from ]; } else { dx = F.dx[from ]; dy = F.dy[from ]; dz = F.dz[from ]; } const Lx = F.lx[from ], Ly = F.ly[from ], Lz = F.lz[from ]; let parent = from ; for (let k = 0 ; k < n && F.m < limit; k++) { // 影の薄い向き(影の格子の勾配の逆)に、空がふさがれた偏りの逆(開けた側)を足す const gx = shadeAt(F, x - c, y, z) - shadeAt(F, x + c, y, z), gy = shadeAt(F, x, y - c, z) - shadeAt(F, x, y + c, z), gz = shadeAt(F, x, y, z - c) - shadeAt(F, x, y, z + c); const gk = 1 / Math.max(Math.hypot(gx, gy, gz), G0); let vx = gx * gk + KA * Lx, vy = gy * gk + KA * Ly, vz = gz * gk + KA * Lz; const vn = Math.hypot(vx, vy, vz); if (vn > 1 ) { vx /= vn; vy /= vn; vz /= vn; } const jit = 0.06 ; // 芽ごとの小さなばらつき dx += g.xi * vx + (tr.rng() - 0.5 ) * jit; dy += g.xi * vy + ETA + (tr.rng() - 0.5 ) * jit; dz += g.xi * vz + (tr.rng() - 0.5 ) * jit; let dn = Math.hypot(dx, dy, dz) || 1 ; dx /= dn; dy /= dn; dz /= dn; if (y + dy * l < 0.05 ) { dy = Math.max(dy, 0.2 ); dn = Math.hypot(dx, dy, dz); dx /= dn; dy /= dn; dz /= dn; } const ex = x + dx * l, ey = y + dy * l, ez = z + dz * l; let blocked = false ; // 建物の中へは伸びない for (const b of F.boxes) if (ex > b.x0 - 0.05 && ex < b.x1 + 0.05 && ey < b.y1 + 0.05 && ez > b.z0 - 0.05 && ez < b.z1 + 0.05 ) blocked = true ; if (blocked) break ; // 葉と脇芽の向き: 前の節の向きを、新しい茎に直角な面へ写してから、茎のまわりに開度 δ だけ回す const dot = rx * dx + ry * dy + rz * dz; rx -= dot * dx; ry -= dot * dy; rz -= dot * dz; let rn = Math.hypot(rx, ry, rz); if (rn < 1e-6 ) { rx = -dz; ry = 0 ; rz = dx; rn = Math.hypot(rx, ry, rz) || 1 ; if (rn < 1e-6 ) { rx = 1 ; rz = 0 ; rn = 1 ; } } rx /= rn; ry /= rn; rz /= rn; const a = g.div + (tr.rng() - 0.5 ) * 0.03 , ca = Math.cos(a), sa = Math.sin(a); const ux = dy * rz - dz * ry, uy = dz * rx - dx * rz, uz = dx * ry - dy * rx; const nrx = ca * rx + sa * ux, nry = ca * ry + sa * uy, nrz = ca * rz + sa * uz; rx = nrx; ry = nry; rz = nrz; const i = F.m++; F.par[i] = parent; F.tree[i] = F.tree[from ]; F.lat[i] = k === 0 && lateral ? 1 : 0 ; F.px[i] = x; F.py[i] = y; F.pz[i] = z; F.dx[i] = dx; F.dy[i] = dy; F.dz[i] = dz; F.len[i] = l; F.rx[i] = rx; F.ry[i] = ry; F.rz[i] = rz; F.born[i] = F.step; F.tip[i] = 0 ; F.bud[i] = 1 ; F.budAge[i] = 0 ; F.pipes[i] = 0 ; F.leaf[i] = 1 ; F.tq[i] = 0 ; F.bq[i] = 0 ; F.rt[i] = 0 ; F.lx[i] = Lx; F.ly[i] = Ly; F.lz[i] = Lz; parent = i; x = ex; y = ey; z = ez; } if (parent !== from ) F.tip[parent] = 1 ; // 新しい枝の先に頂芽 } // 養分が 1 以上届いた芽が伸びる。頂芽は、1 に届かない年の養分をためて次の年に回す(暗い所でも少しずつ伸びる)。 // 脇芽はためない(1 に届かなければ眠ったまま)。そのあと、眠った脇芽が 1 年ぶん年をとる。 // 節の数が上限に届いているときは、芽を使わずに残す(枝が落ちて空きができたら、次の年に伸びる) function grow(F: Forest, g: Gp, limit: number) { const m0 = F.m; for (let i = 0 ; i < m0; i++) { const tr = F.trees[F.tree[i]]; if (F.tip[i]) { const v = F.vm[i] + F.rt[i]; if (v >= 1 && F.m < limit) { F.tip[i] = 0 ; F.rt[i] = 0 ; addShoot(F, g, i, v, false , tr, limit); } else F.rt[i] = Math.min(v, 1 ); } if (F.bud[i] && F.vl[i] >= 1 && F.m < limit) { F.bud[i] = 0 ; addShoot(F, g, i, F.vl[i], true , tr, limit); } } for (let i = 0 ; i < m0; i++) if (F.bud[i] && ++F.budAge[i] > F.look.budLife) F.bud[i] = 0 ; } // 葉は若い節と頂芽のある節にだけ残す。パイプモデル: 節が支える葉の数を根元へ足し上げ、いちばん多かった数を覚える function leavesAndPipes(F: Forest) { const ly = F.look.leafYears * STEPS; for (let i = 0 ; i < F.m; i++) { F.leaf[i] = F.tip[i] || F.step - F.born[i] < ly ? 1 : 0 ; F.cnt[i] = F.leaf[i]; } for (let i = F.m - 1 ; i > 0 ; i--) { const p = F.par[i]; if (p >= 0 ) F.cnt[p] += F.cnt[i]; } for (let i = 0 ; i < F.m; i++) if (F.cnt[i] > F.pipes[i]) F.pipes[i] = F.cnt[i]; } // 枝 i とその先をすべて切る function cutAt(F: Forest, i0: number) { for (let i = 0 ; i < F.m; i++) { const p = F.par[i]; F.kill[i] = i === i0 || (p >= 0 && F.kill[p]) ? 1 : 0 ; } let n = 0 ; for (let i = 0 ; i < F.m; i++) n += F.kill[i]; compact(F); return n; }
節の向きは、前の節の向きに、影の薄い向き・開けた空の向き・上向きを足して決める。影の薄い向きは、まわりのますの影を左右・上下・前後で比べた差(影の勾配)の逆向きである。勾配が弱いときは足す量も小さくなるよう、G0より短い勾配はG0で割る。開けた空の向きは、建物や林で空の一部がふさがれているときだけ0でなくなる。この2つの和に屈光性ξ を掛け、上向きのETAと、芽ごとの小さなばらつきを加える。
葉と脇芽の向きは、茎に直角な向きrで持つ。新しい節では、前のrを新しい茎に直角な面へ写してから、茎のまわりに開度δ だけ回す。回すときは、rと「茎の向き × r」の2本を、cos δ とsin δ で混ぜる。脇芽は、茎から枝の角度β だけrの側へ開いた向きに伸びる。
leavesAndPipesはパイプモデルの計算で、節ごとに、その先にある葉の数を根元へ足し上げ、これまでで最も多かった数をpipesに残す。描くときの直径はd 0 × pipes1/ n で、n は「太さの指数」のつまみである。
重い計算を1年の刻みに分ける
影をますにためる計算は、節の数×四角すいのます(最大455)だけ足し算をする。1万節近い木では、これを1コマで行うと重い。そこでdoStepは、刻み0〜3で影を4分の1ずつ、刻み4と5で日当たりを半分ずつ、刻み6で養分と枝落とし、刻み7で成長、刻み8で測る、と仕事を分けている。
1年の進め方(src/sims/plant-growth.ts の 365〜497 行) // 1 刻みぶん進める。重い計算は 1 年のうちの別々の刻みに分けて、1 コマが重くなりすぎないようにする // 刻み 0〜3 影の格子(4 分の 1 ずつ) / 4・5 芽の日当たり(半分ずつ) / 6 養分を配り、日の当たらない枝を落とす / 7 芽が伸びる / 8 測る function doStep(api: Api, s: S) { const F = s.F, k = F.step % STEPS, g = gpOf(api); if (F.year < s.R.stop) { if (k <= 3 ) { if (k === 0 ) { F.grid.fill(0 ); s.castM = F.m; } const m = Math.min(s.castM, F.m); castShadows(F, Math.floor((m * k) / 4 ), Math.floor((m * (k + 1 )) / 4 )); } else if (k === 4 ) measure(F, 0 , s.castM >> 1 ); else if (k === 5 ) { measure(F, s.castM >> 1 , F.m); tipLight(s); } else if (k === 6 ) { allocate(F, g); if (shed(F)) { s.dirty = true ; onceNote(api, s, "shed" , "日の当たらない枝が、枯れて落ちはじめた" ); } } else if (k === 7 ) { const m0 = F.m; grow(F, g, s.limit); // 切ってから 2 年のあいだに、切った高さから 1 m 下までの節の脇芽から伸び出した横枝を数える(木 A・木 B) if (s.cutStep >= 0 && F.step - s.cutStep <= 2 * STEPS) for (let i = m0; i < F.m; i++) if (F.lat[i] && F.tree[i] < 2 && F.py[i] < s.cutH + 0.01 && F.py[i] > s.cutH - 1 ) s.sprout[F.tree[i]]++; F.year++; leavesAndPipes(F); if (F.m >= s.limit) onceNote(api, s, "cap" , "節の数が上限に届いた。枝が落ちて空きができるまで、新しい枝は伸びない" ); const c = s.R.cut; if (c && F.year === c.year) cutLeader(api, s, c.tree, c.frac); if (F.year >= s.R.stop) onceNote(api, s, "stop" , s.R.look === SHOOT ? "1年分の茎ができた。開度を変えると、すぐに育て直す" : ` ${s.R.stop}年たったので成長を止めた。場面を選び直すと苗からやり直せる` ); s.dirty = true ; } } if (k === 8 && s.statYear !== F.year) { s.statYear = F.year; measureStats(s); } F.step++; } // 木 t の主軸を、高さの frac の所で切る function cutLeader(api: Api, s: S, t: number, frac: number) { const F = s.F, tr = F.trees[t]; if (!tr || tr.root < 0 ) return ; const main = new Int32Array(F.m).fill(-1 ); for (let i = 0 ; i < F.m; i++) if (F.par[i] >= 0 && !F.lat[i]) main[F.par[i]] = i; const axis: number[] = []; for (let i = tr.root; i >= 0 ; i = main[i]) axis.push(i); const top = axis[axis.length - 1 ], h = F.py[top] + F.dy[top] * F.len[top], at = h * frac; const i0 = axis.find((i) => F.py[i] >= at); if (i0 === undefined ) return ; s.cutStep = F.step; s.cutH = F.py[i0]; cutAt(F, i0); s.dirty = true ; api.note(` 木${String.fromCharCode(65 + t)}の先端を、高さ ${fmt(s.cutH, 1)} m から上で切った` , 3000 ); } // 葉に日が当たる割合: 葉を太陽の向きから見た平面に写し、重なりを除いた面積 ÷ 葉の面積の合計(画素で数える) const CAPN = 256 , capBuf = new Uint8Array(CAPN * CAPN);const LF = new Float32Array(12 );// 節 i の葉の付け根・葉の向き b・葉の面の向き n function leafFrame(F: Forest, i: number, out: Float32Array) { let hx = F.rx[i], hz = F.rz[i], hn = Math.hypot(hx, hz); if (hn < 0.3 ) { hx = F.dx[i]; hz = F.dz[i]; hn = Math.hypot(hx, hz); if (hn < 1e-6 ) { hx = 1 ; hz = 0 ; hn = 1 ; } } hx /= hn; hz /= hn; let bx = hx, by = 0.25 , bz = hz; const bn = Math.hypot(bx, by, bz); bx /= bn; by /= bn; bz /= bn; // 少し上を向いた葉 let nx = -by * bx, ny = 1 - by * by, nz = -by * bz; const nn = Math.hypot(nx, ny, nz); nx /= nn; ny /= nn; nz /= nn; const L = F.look.leafL, ex = F.px[i] + F.dx[i] * F.len[i], ey = F.py[i] + F.dy[i] * F.len[i], ez = F.pz[i] + F.dz[i] * F.len[i]; out[0 ] = ex + bx * 0.1 * L; out[1 ] = ey + by * 0.1 * L; out[2 ] = ez + bz * 0.1 * L; out[3 ] = bx; out[4 ] = by; out[5 ] = bz; out[6 ] = nx; out[7 ] = ny; out[8 ] = nz; out[9 ] = by * nz - bz * ny; out[10 ] = bz * nx - bx * nz; out[11 ] = bx * ny - by * nx; // 葉の幅の向き b × n } const CU = new Float32Array(4 ), CW = new Float32Array(4 );let capPts = new Float32Array(0 );function lightCapture(F: Forest) { const sd = F.sun, L = F.look.leafL, W = F.look.leafW; let ux = -sd[2 ], uz = sd[0 ]; const un = Math.hypot(ux, uz); if (un < 1e-6 ) { ux = 1 ; uz = 0 ; } else { ux /= un; uz /= un; } const wx = sd[1 ] * uz, wy = sd[2 ] * ux - sd[0 ] * uz, wz = -sd[1 ] * ux; // w = 太陽の向き × u if (capPts.length < F.m * 8 ) capPts = new Float32Array(F.cap * 8 ); const P = capPts, A = [0 , 0.45 * L, L, 0.45 * L], B = [0 , 0.5 * W, 0 , -0.5 * W]; let n = 0 , u0 = Infinity, u1 = -Infinity, w0 = Infinity, w1 = -Infinity; for (let i = 0 ; i < F.m; i++) { if (!F.leaf[i]) continue ; leafFrame(F, i, LF); for (let c = 0 ; c < 4 ; c++) { const x = LF[0 ] + LF[3 ] * A[c] + LF[9 ] * B[c], y = LF[1 ] + LF[4 ] * A[c] + LF[10 ] * B[c], z = LF[2 ] + LF[5 ] * A[c] + LF[11 ] * B[c]; const pu = x * ux + z * uz, pw = x * wx + y * wy + z * wz; P[n * 8 + c * 2 ] = pu; P[n * 8 + c * 2 + 1 ] = pw; if (pu < u0) u0 = pu; if (pu > u1) u1 = pu; if (pw < w0) w0 = pw; if (pw > w1) w1 = pw; } n++; } if (!n) return NaN; const sc = (CAPN - 2 ) / Math.max(1e-6 , u1 - u0, w1 - w0); capBuf.fill(0 ); let total = 0 , union = 0 ; for (let k = 0 ; k < n; k++) { let a0 = CAPN, a1 = 0 , b0 = CAPN, b1 = 0 ; for (let c = 0 ; c < 4 ; c++) { const X = (P[k * 8 + c * 2 ] - u0) * sc, Y = (P[k * 8 + c * 2 + 1 ] - w0) * sc; CU[c] = X; CW[c] = Y; if (X < a0) a0 = X; if (X > a1) a1 = X; if (Y < b0) b0 = Y; if (Y > b1) b1 = Y; } const i0 = Math.max(0 , Math.floor(a0)), i1 = Math.min(CAPN - 1 , Math.ceil(a1)), j0 = Math.max(0 , Math.floor(b0)), j1 = Math.min(CAPN - 1 , Math.ceil(b1)); for (let py = j0; py <= j1; py++) { // 1 行ずつ、葉の四角形が覆う区間を塗る const cy = py + 0.5 ; let xa = Infinity, xb = -Infinity; for (let e = 0 ; e < 4 ; e++) { const f = (e + 1 ) & 3 , ya = CW[e], yb = CW[f]; if (cy < ya === cy < yb) continue ; const x = CU[e] + ((cy - ya) / (yb - ya)) * (CU[f] - CU[e]); if (x < xa) xa = x; if (x > xb) xb = x; } const p0 = Math.max(i0, Math.ceil(xa - 0.5 )), p1 = Math.min(i1, Math.floor(xb - 0.5 )); if (p1 < p0) continue ; total += p1 - p0 + 1 ; for (let q = py * CAPN + p0, qe = py * CAPN + p1; q <= qe; q++) if (!capBuf[q]) { capBuf[q] = 1 ; union++; } } } return total ? union / total : NaN; } // 木 A のてっぺんの芽(いちばん高い頂芽)の日当たり。伸びる前に測る function tipLight(s: S) { const F = s.F; let q = NaN, h = -1 ; for (let i = 0 ; i < F.m; i++) if (F.tree[i] === 0 && F.tip[i]) { const y = F.py[i] + F.dy[i] * F.len[i]; if (y > h) { h = y; q = F.tq[i]; } } s.st.tipQ = q; } // 木 A の高さ・幹の直径と、場面ごとの値 function measureStats(s: S) { const F = s.F, st = s.st, n = s.pipeN; const t0 = F.trees[0 ]; st.h = NaN; st.d = NaN; st.cx = NaN; st.cz = NaN; if (t0 && t0.root >= 0 ) { let h = 0 , lx = 0 , lz = 0 , nl = 0 ; for (let i = 0 ; i < F.m; i++) { if (F.tree[i] !== 0 ) continue ; h = Math.max(h, F.py[i] + F.dy[i] * F.len[i]); if (F.leaf[i]) { lx += F.px[i]; lz += F.pz[i]; nl++; } } st.h = h; st.d = F.look.d0 * Math.pow(F.pipes[t0.root], 1 / n); if (nl) { st.cx = lx / nl; st.cz = lz / nl; } } const ex = s.R.extra; if (ex === "capture" ) st.extra = lightCapture(F); else if (ex === "shift" ) st.extra = t0 && t0.root >= 0 ? Math.hypot(st.cx - t0.x, st.cz - t0.z) : NaN; else if (ex === "floor" ) st.extra = t0 ? skyLight(F, t0.x, 0.3 , t0.z, O3) : NaN; else if (ex === "sprout" ) { st.extra = s.sprout[0 ]; st.extra2 = s.sprout[1 ]; } }
「葉に日が当たる割合」はlightCaptureで数える。葉の四角形を太陽の向きから見た平面に写し、256×256の画素に1行ずつ塗る。どれかの葉が塗った画素の数(重なりを除いた面積)を、葉ごとに数えた画素の合計で割った値である。本文で紹介したStraussらの計算と同じ考え方だ。
three.jsに渡すのは行列だけ
枝の円柱と葉の板は、それぞれ1つのInstancedMesh(同じ形をたくさん並べる部品)で描く。節1つにつき行列を1つ書き込むだけなので、1万本の枝でも形を作り直さずに済む。
描く(src/sims/plant-growth.ts の 521〜637 行) // three.js が受け持つのは、立体(枝の円柱・葉の板・地面・建物)と影と視点。ここでは、各節の円柱と葉の板の置き場所(行列)を書き込む const put = (a: Float32Array, k: number, X: number[], Y: number[], Z: number[], P: number[]) => { const o = k * 16 ; a[o] = X[0 ]; a[o + 1 ] = X[1 ]; a[o + 2 ] = X[2 ]; a[o + 3 ] = 0 ; a[o + 4 ] = Y[0 ]; a[o + 5 ] = Y[1 ]; a[o + 6 ] = Y[2 ]; a[o + 7 ] = 0 ; a[o + 8 ] = Z[0 ]; a[o + 9 ] = Z[1 ]; a[o + 10 ] = Z[2 ]; a[o + 11 ] = 0 ; a[o + 12 ] = P[0 ]; a[o + 13 ] = P[1 ]; a[o + 14 ] = P[2 ]; a[o + 15 ] = 1 ; }; const VX = [0 , 0 , 0 ], VY = [0 , 0 , 0 ], VZ = [0 , 0 , 0 ], VP = [0 , 0 , 0 ];// 節 i を描く。f は伸びた割合(0〜1)。根元は親の描いた先端につなぐ function drawNode(s: S, api: Api, i: number, now: number) { const F = s.F, look = F.look, p = F.par[i]; const f = clamp((now - F.born[i]) / GROW, 0 , 1 ); const x = p >= 0 ? s.ex[p] : F.px[i], y = p >= 0 ? s.ey[p] : F.py[i], z = p >= 0 ? s.ez[p] : F.pz[i]; const L = Math.max(1e-4 , F.len[i] * f); s.ex[i] = x + F.dx[i] * L; s.ey[i] = y + F.dy[i] * L; s.ez[i] = z + F.dz[i] * L; const r = Math.max(look.rmin, 0.5 * look.d0 * Math.pow(Math.max(1 , F.pipes[i]), 1 / s.pipeN)) * (0.4 + 0.6 * f); // 描く最も細い枝は rmin const dx = F.dx[i], dy = F.dy[i], dz = F.dz[i], rx = F.rx[i], ry = F.ry[i], rz = F.rz[i]; VX[0 ] = rx * r; VX[1 ] = ry * r; VX[2 ] = rz * r; VY[0 ] = dx * L; VY[1 ] = dy * L; VY[2 ] = dz * L; VZ[0 ] = (ry * dz - rz * dy) * r; VZ[1 ] = (rz * dx - rx * dz) * r; VZ[2 ] = (rx * dy - ry * dx) * r; VP[0 ] = x; VP[1 ] = y; VP[2 ] = z; put(s.wood.instanceMatrix.array as Float32Array, i, VX, VY, VZ, VP); const k = s.leafIdx[i]; if (k >= 0 ) { leafFrame(F, i, LF); const g = f * f, sx = look.leafL * g, sz = look.leafW * g; VX[0 ] = LF[3 ] * sx; VX[1 ] = LF[4 ] * sx; VX[2 ] = LF[5 ] * sx; VY[0 ] = LF[6 ]; VY[1 ] = LF[7 ]; VY[2 ] = LF[8 ]; VZ[0 ] = LF[9 ] * sz; VZ[1 ] = LF[10 ] * sz; VZ[2 ] = LF[11 ] * sz; VP[0 ] = LF[0 ] - F.px[i] - F.dx[i] * F.len[i] + s.ex[i]; VP[1 ] = LF[1 ] - F.py[i] - F.dy[i] * F.len[i] + s.ey[i]; VP[2 ] = LF[2 ] - F.pz[i] - F.dz[i] * F.len[i] + s.ez[i]; put(s.leaves.instanceMatrix.array as Float32Array, k, VX, VY, VZ, VP); } } // 全部の節を描き直す(年に 1 回と、切ったり植えたりしたとき) function rebuild(s: S, api: Api) { const F = s.F, now = F.step + s.acc; let nl = 0 ; s.animFrom = F.m; for (let i = 0 ; i < F.m; i++) { if (s.animFrom === F.m && now - F.born[i] < GROW) s.animFrom = i; if (F.leaf[i]) { s.leafIdx[i] = nl; s.leafOf[nl] = i; nl++; } else s.leafIdx[i] = -1 ; } s.leafAnimFrom = nl; for (let i = s.animFrom; i < F.m; i++) if (s.leafIdx[i] >= 0 ) { s.leafAnimFrom = s.leafIdx[i]; break ; } for (let i = 0 ; i < F.m; i++) drawNode(s, api, i, now); s.nLeaves = nl; for (const mesh of [s.wood, s.leaves]) { mesh.instanceMatrix.clearUpdateRanges(); mesh.instanceMatrix.needsUpdate = true ; mesh.boundingSphere = null ; mesh.boundingBox = null ; } s.wood.count = F.m; s.leaves.count = nl; } // 伸びている途中の節だけ計算し直す(毎コマ)。GPU へは配列全体を送り直す(場面を切り替えたあとも確実に描けるように) function animate(s: S, api: Api) { const F = s.F, now = F.step + s.acc; if (s.animFrom >= F.m) return ; for (let i = s.animFrom; i < F.m; i++) drawNode(s, api, i, now); s.wood.instanceMatrix.needsUpdate = true ; if (s.leafAnimFrom < s.nLeaves) s.leaves.instanceMatrix.needsUpdate = true ; if (now - F.born[F.m - 1 ] >= GROW) s.animFrom = F.m; } // 建物と、林の大きな木(幹と、楕円体の冠)。冠の形は、光の計算で使う楕円体と同じ function buildStatics(s: S) { const F = s.F, b = F.boxes[0 ]; s.building.visible = !!b; if (b) { s.building.scale.set(b.x1 - b.x0, b.y1 - b.y0, b.z1 - b.z0); s.building.position.set((b.x0 + b.x1) / 2 , (b.y0 + b.y1) / 2 , (b.z0 + b.z1) / 2 ); } const wa = s.cWood.instanceMatrix.array as Float32Array, ca = s.cCrown.instanceMatrix.array as Float32Array; let n = 0 ; F.crowns.forEach((c, ci) => { if (!c.alive) return ; const top = c.y0 + 0.5 * (c.y1 - c.y0), ry = (c.y1 - c.y0) / 2 ; VX[0 ] = 0.13 ; VX[1 ] = 0 ; VX[2 ] = 0 ; VY[0 ] = 0 ; VY[1 ] = top; VY[2 ] = 0 ; VZ[0 ] = 0 ; VZ[1 ] = 0 ; VZ[2 ] = 0.13 ; VP[0 ] = c.x; VP[1 ] = 0 ; VP[2 ] = c.z; put(wa, n, VX, VY, VZ, VP); VX[0 ] = c.r; VX[1 ] = 0 ; VX[2 ] = 0 ; VY[0 ] = 0 ; VY[1 ] = ry; VY[2 ] = 0 ; VZ[0 ] = 0 ; VZ[1 ] = 0 ; VZ[2 ] = c.r; VP[0 ] = c.x; VP[1 ] = c.y0 + ry; VP[2 ] = c.z; put(ca, n, VX, VY, VZ, VP); s.cOf[n++] = ci; }); s.cWood.count = n; s.cCrown.count = n; for (const mesh of [s.cWood, s.cCrown]) { mesh.instanceMatrix.needsUpdate = true ; mesh.boundingSphere = null ; mesh.boundingBox = null ; } } const R_SUN = (s: S, r: number) => (s.R.look === SHOOT ? r / 25 : r / 15 );function placeSun(s: S) { const t = s.v.orbit.target, d = s.F.sun; const r = s.R.look === SHOOT ? s.sunR : Math.max(s.sunR, s.v.orbit.dist * 0.42 ); s.sunMesh.position.set(t.x + d[0 ] * r, t.y + d[1 ] * r, t.z + d[2 ] * r); s.sunMesh.scale.setScalar(R_SUN(s, r)); s.v.sunAt([d[0 ], d[1 ], d[2 ]], new THREE.Vector3(t.x, 0 , t.z)); } // 描画面の文字と印: 太陽の取っ手・木の名前・冠の片寄りの矢印・長さの目盛り function overlay(api: Api, s: S) { const F = s.F, v = s.v, shoot = F.look === SHOOT; const sp = v.project(s.sunMesh.position), m = api.u(26 ); const hx = clamp(sp.x, m, api.w - m), hy = clamp(sp.y, m, api.h - m); s.handle.x = hx; s.handle.y = hy; api.circle(hx, hy, api.u(15 ), { w: api.u(1.4 ), dash: [api.u(3 ), api.u(3 )] }); if (hx !== sp.x || hy !== sp.y) { // 太陽が画面の外にあるときは、取っ手を縁に置き、太陽のある向きを矢印で示す const dx = sp.x - hx, dy = sp.y - hy, n = Math.hypot(dx, dy) || 1 ; api.arrow(hx, hy, hx + (dx / n) * api.u(13 ), hy + (dy / n) * api.u(13 ), { w: api.u(1.2 ), head: api.u(6 ) }); } const right = hx > api.w - api.u(70 ); api.label("太陽" , hx + (right ? -api.u(20 ) : api.u(20 )), hy + api.u(5 ), { size: api.u(12 ), align: right ? "right" : "left" }); if (!shoot) F.trees.forEach((t, k) => { if (t.root < 0 ) return ; const p = v.project([t.x, 0 , t.z]); if (p.on) api.label(String.fromCharCode(65 + k), p.x + api.u(6 ), p.y + api.u(16 ), { size: api.u(13 ), weight: 600 }); }); const t0 = F.trees[0 ]; if (s.R.extra === "shift" && t0 && t0.root >= 0 && Number.isFinite(s.st.cx)) { const a = v.project([t0.x, 0.02 , t0.z]), b = v.project([s.st.cx, 0.02 , s.st.cz]); if (a.on && b.on && Math.hypot(b.x - a.x, b.y - a.y) > api.u(6 )) api.arrow(a.x, a.y, b.x, b.y, { w: api.u(2 ), head: api.u(8 ) }); } // 長さの目盛り(木の場面は 1 m、葉の並びは 10 cm) const unit = shoot ? 0.1 : 1 , bx = t0 ? t0.x + (shoot ? 0.18 : -2.6 ) : 1.5 , bz = t0 ? t0.z + (shoot ? 0.12 : 1.4 ) : 0 ; const a = v.project([bx, 0.01 , bz]), b = v.project([bx + unit, 0.01 , bz]); if (a.on && b.on && Math.hypot(b.x - a.x, b.y - a.y) > api.u(8 )) { api.line(a.x, a.y, b.x, b.y, { w: api.u(2 ) }); api.label(shoot ? "10 cm" : "1 m" , (a.x + b.x) / 2 , Math.max(a.y, b.y) + api.u(16 ), { size: api.u(11 ), align: "center" , font: "mono" }); } }
行列の3つの列には、円柱の横向き(r×半径)、縦向き(茎の向き×長さ)、もう1つの横向き(2つの外積×半径)を入れ、4列目に根元の位置を入れる。伸びている途中の節だけは、親の先端に根元をつなぎ直しながら毎コマ計算し直す。木全体を描き直すのは、1年に1回と、枝を切ったり苗を植えたりしたときだけである。白と黒の網点と輪郭線への変換は、共通部品が受け持つ。
太陽をつかむ、枝を切る、苗を植える
描画面を押したとき、太陽の取っ手の近くなら太陽を動かす。横のドラッグで方角、縦のドラッグで高さが変わり、高さは12〜88°に収める。
操作(src/sims/plant-growth.ts の 655〜696 行) // 太陽の取っ手をドラッグすると太陽が動く(横で方角、縦で高さ)。枝か葉をタップすると切り、地面をタップすると苗を植える。 // 林の大きな木をタップすると切り倒す。それ以外のドラッグは視点を回す const GROUND = new THREE.Plane(new THREE.Vector3(0 , 1 , 0 ), 0 );function tap(api: Api, s: S) { const F = s.F, v = s.v, x = api.pointer.x, y = api.pointer.y; const objs: THREE.Object3D[] = [s.wood, s.leaves, s.cWood, s.cCrown, s.building]; const hit = v.pick(x, y, objs.filter((o) => o.visible && (!(o instanceof THREE.InstancedMesh) || o.count > 0 ))); if (hit && hit.instanceId !== undefined && (hit.object === s.wood || hit.object === s.leaves)) { const i = hit.object === s.wood ? hit.instanceId : s.leafOf[hit.instanceId]; if (i < 0 || i >= F.m) return ; const t = F.tree[i], whole = F.par[i] < 0 , n = cutAt(F, i); s.dirty = true ; measureStats(s); api.note(whole ? ` 木${String.fromCharCode(65 + t)}を根元から切った` : ` 枝を切った(${n}節)。すぐ下の脇芽に養分が回る` , 2600 ); return ; } if (hit && hit.instanceId !== undefined && (hit.object === s.cWood || hit.object === s.cCrown)) { const ci = s.cOf[hit.instanceId]; const c = F.crowns[ci]; if (!c || !c.alive) return ; c.alive = false ; buildStatics(s); measureStats(s); const left = F.crowns.filter((q) => q.alive).length; api.note(` 大きな木を 1 本切った(残り ${left} 本)。林の床が明るくなる` , 2600 ); return ; } if (hit && hit.object === s.building) return ; const g = v.onPlane(x, y, GROUND); if (!g) return ; const lim = F.look.half - 1 ; if (Math.abs(g.x) > lim || Math.abs(g.z) > lim) { api.note("ここは計算の範囲の外なので植えられない" , 2000 ); return ; } if (F.boxes.some((b) => g.x > b.x0 - 0.3 && g.x < b.x1 + 0.3 && g.z > b.z0 - 0.3 && g.z < b.z1 + 0.3 )) return ; if (F.trees.some((t) => t.root >= 0 && Math.hypot(t.x - g.x, t.z - g.z) < 0.5 )) return ; if (F.trees.length >= MAXTREES) { api.note(` 苗は ${MAXTREES} 本まで` , 2000 ); return ; } if (plant(F, g.x, g.z, api.rng(500 + F.trees.length)) >= 0 ) { s.dirty = true ; api.note(` 苗を植えた(木${String.fromCharCode(64 + F.trees.length)})` , 2000 ); } } function dragSun(api: Api, s: S) { const p = api.pointer, d = s.F.sun; let az = Math.atan2(d[0 ], d[2 ]), el = Math.asin(clamp(d[1 ], -1 , 1 )); const k = 3.2 / Math.max(320 , Math.min(api.w, api.h * 1.6 )); az += (p.x - s.handle.x) * k * 0.6 ; el = clamp(el - (p.y - s.handle.y) * k * 0.6 , 12 * DEG, 88 * DEG); s.handle.x = p.x; s.handle.y = p.y; s.F.sun = sunFrom(el / DEG, az / DEG); placeSun(s); }
指を動かさずに離したときは、光線が当たったものから順に、枝か葉ならcutAtでその節から先を切り、林の木なら切り倒し、何にも当たらなければ地面に苗を植える。それ以外のドラッグは視点を回す操作として、共通部品のorbitに渡す。