display.wgsl

GitHub

@group(0) @binding(0) var hdr: texture_2d<f32>;
@vertex fn fullscreen(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
    let p = vec2f(f32((i << 1u) & 2u), f32(i & 2u));
    return vec4f(p * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn display(@builtin(position) position: vec4f) -> @location(0) vec4f {
    let linear = max(textureLoad(hdr, vec2i(position.xy), 0).rgb, vec3f(0.0));
    // ACES Filmicの近似後に、区分関数でsRGBへ変換する。
    let mapped = clamp((linear * (2.51 * linear + 0.03)) / (linear * (2.43 * linear + 0.59) + 0.14), vec3f(0), vec3f(1));
    let srgb = select(1.055 * pow(mapped, vec3f(1.0 / 2.4)) - 0.055, 12.92 * mapped, mapped <= vec3f(0.0031308));
    return vec4f(srgb, 1.0);
}

preview.wgsl

GitHub

struct Camera { viewProjection: mat4x4f, display: vec4u }
@group(0) @binding(0) var<uniform> camera: Camera;
struct Vertex {
    @location(0) position: vec3f,
    @location(1) normal: vec3f,
    @location(2) color: vec3f,
}
struct Surface {
    @builtin(position) @invariant position: vec4f,
    @location(0) normal: vec3f,
    @location(1) color: vec3f,
}
// ZOnlyとForwardで位置計算を共有し、Equal比較で同じ面を描く。
fn project(position: vec3f) -> vec4f {
    return camera.viewProjection * vec4f(position, 1.0);
}
@vertex fn depthVertex(v: Vertex) -> @builtin(position) @invariant vec4f {
    return project(v.position);
}
@vertex fn surfaceVertex(v: Vertex) -> Surface {
    return Surface(project(v.position), v.normal, v.color);
}
struct Placement { positionScale: vec4f, rotation: vec4f, variation: vec4u }
struct LeafClass { verticesPerVariation: u32, reserved0: u32, reserved1: u32, reserved2: u32 }
@group(1) @binding(0) var<storage, read> leafVertices: array<f32>;
@group(1) @binding(1) var<storage, read> placements: array<Placement>;
@group(1) @binding(2) var<uniform> leafClass: LeafClass;

fn leafAddress(vertexIndex: u32, placement: Placement) -> u32 {
    // vertexIndexは固定機能の共有IBの値。個体のバリエーションだけをオフセットに足す。
    return (placement.variation.x * leafClass.verticesPerVariation + vertexIndex) * 9u;
}
fn readLeaf3(at: u32) -> vec3f {
    return vec3f(leafVertices[at], leafVertices[at + 1u], leafVertices[at + 2u]);
}
fn rotateVector(v: vec3f, q: vec4f) -> vec3f {
    return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v);
}
fn leafPosition(at: u32, placement: Placement) -> vec4f {
    return project(rotateVector(readLeaf3(at), placement.rotation) * placement.positionScale.w + placement.positionScale.xyz);
}
@vertex fn leafDepthVertex(@builtin(vertex_index) vertexIndex: u32, @builtin(instance_index) instanceIndex: u32)
    -> @builtin(position) @invariant vec4f {
    let placement = placements[instanceIndex];
    return leafPosition(leafAddress(vertexIndex, placement), placement);
}
@vertex fn leafSurfaceVertex(@builtin(vertex_index) vertexIndex: u32, @builtin(instance_index) instanceIndex: u32) -> Surface {
    let placement = placements[instanceIndex];
    let at = leafAddress(vertexIndex, placement);
    return Surface(leafPosition(at, placement), rotateVector(readLeaf3(at + 3u), placement.rotation), readLeaf3(at + 6u));
}
@fragment fn shade(v: Surface, @builtin(front_facing) front: bool) -> @location(0) vec4f {
    // 輪郭の確認では照明・頂点カラーを使わず白一色で描く。
    if (camera.display.x != 0u) { return vec4f(4.0,4.0,4.0,1.0); }
    // 葉カードは両面を描く。裏面の法線を反転し、discardは使わない。
    let normal = normalize(v.normal) * select(-1.0, 1.0, front);
    let light = normalize(vec3f(0.6, 0.8, 0.5));
    // 初版の形状確認用の照明。環境光は天空IBLではなく簡易な上下グラデーション。
    let ambient = mix(vec3f(0.13, 0.12, 0.10), vec3f(0.5, 0.65, 0.8), normal.y * 0.5 + 0.5);
    let diffuse = vec3f(5.0) * max(dot(normal, light), 0.0) / 3.14159265;
    return vec4f(v.color * (diffuse + ambient), 1.0);
}

// モデリング用の骨格中心線。幹メッシュとは独立して表示する。
@vertex fn lineVertex(@location(0) position: vec3f) -> @builtin(position) vec4f {
    return project(position);
}
@fragment fn lineColor() -> @location(0) vec4f {
    return vec4f(1.0, 0.35, 0.04, 1.0);
}

branch-cluster.ts

GitHub

import type { BranchElement, LeafClass, LeafElement, LibraryParameters, Vec3 } from './leaf-library.js';

export interface BranchClassSpec {
    name: string;
    triangles: number;
    segments: number;
    leaves: number;
    extent: number;
}
export const BRANCH_CLASSES: readonly BranchClassSpec[] = [
    { name: '小:32tri', triangles: 32, segments: 2, leaves: 10, extent: 0.7 },
    { name: '中:64tri', triangles: 64, segments: 4, leaves: 20, extent: 0.85 },
    { name: '大:128tri', triangles: 128, segments: 4, leaves: 52, extent: 1 }
];
export const BRANCH_PRESETS = [
    { name: '広葉樹・扇状', type: 'broadleaf', width: 1.2, depth: 0.8, rise: 0.9, length: 0.85, leafWidth: 0.44, color: [0.065,0.23,0.045] },
    { name: '広葉樹・丸い塊', type: 'broadleaf', width: 0.85, depth: 1.1, rise: 1.3, length: 0.9, leafWidth: 0.48, color: [0.095,0.29,0.045] },
    { name: '広葉樹・垂れ枝', type: 'broadleaf', width: 1.15, depth: 0.9, rise: 0.4, length: 0.95, leafWidth: 0.4, color: [0.05,0.2,0.075] },
    { name: '松風・枝葉', type: 'pine', width: 0.9, depth: 0.8, rise: 1.1, length: 1.1, leafWidth: 0.075, color: [0.035,0.15,0.055] }
] as const;

const add = (a: Vec3, b: Vec3): Vec3 => [a[0]+b[0],a[1]+b[1],a[2]+b[2]];
const scale = (a: Vec3, s: number): Vec3 => [a[0]*s,a[1]*s,a[2]*s];
const cross = (a: Vec3, b: Vec3): Vec3 => [a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
const unit = (a: Vec3): Vec3 => scale(a,1/Math.hypot(...a));

export function createBranchCluster(spec: BranchClassSpec, parameters: LibraryParameters): LeafClass {
    const vertices: number[] = [], indices: number[] = [];
    const branchVertices = spec.segments*6;
    // 三角断面の側面のみ。端面なしで1区間6三角形を維持する。
    for (let segment=0; segment<spec.segments; segment++) {
        const base=segment*6;
        for (let i=0; i<3; i++) {
            const j=(i+1)%3;
            indices.push(base+i,base+j,base+3+i,base+j,base+3+j,base+3+i);
        }
    }
    for (let leaf=0; leaf<spec.leaves; leaf++) {
        const at=branchVertices+leaf*4;
        indices.push(at,at+1,at+2,at,at+2,at+3);
    }
    const variations = BRANCH_PRESETS.map((preset, variation) => {
        let state=parameters.seed;
        const random=(): number => {
            state=(Math.imul(state,1664525)+1013904223)>>>0;
            return state/4294967296;
        };
        const branches: BranchElement[]=[];
        const hasStem=spec.segments>2;
        const limbCount=hasStem?spec.segments-1:spec.segments;
        if (hasStem) branches.push({start:[0,0,0],end:[0.04,0.6*spec.extent,0],radius0:0.065,radius1:0.036});
        for (let limb=0; limb<limbCount; limb++) {
            // 扇状と丸い塊で奥行き配置を変え、分岐位置と高さもずらして対称な三叉を避ける。
            const angle=(limb/limbCount)*Math.PI*2+0.3;
            const azimuth=preset.name==='広葉樹・丸い塊'?angle:[-2.65,-0.15,1.25][limb];
            const start: Vec3=hasStem?[0.04*(0.4+limb*0.25),(0.23+limb*0.16)*spec.extent,0]:[0,0,0];
            const reach=spec.extent*preset.width*parameters.spread*(0.95+random()*0.1);
            const end: Vec3=[Math.cos(azimuth)*reach,
                (preset.rise+0.3*Math.sin(angle))*spec.extent,
                Math.sin(azimuth)*reach*preset.depth*parameters.depth];
            branches.push({start,end,radius0:0.035*spec.extent,radius1:0.008*spec.extent});
        }
        for (const branch of branches) {
            const delta=add(branch.end,scale(branch.start,-1)), length=Math.hypot(...delta), axis=unit(delta);
            const u=unit(cross(Math.abs(axis[1])<0.9?[0,1,0]:[1,0,0],axis)), v=cross(axis,u);
            for (let ring=0; ring<2; ring++) {
                for (let i=0; i<3; i++) {
                    const angle=i*Math.PI*2/3;
                    const radial=add(scale(u,Math.cos(angle)),scale(v,Math.sin(angle)));
                    const center=ring===0?branch.start:branch.end, radius=ring===0?branch.radius0:branch.radius1;
                    const normal=unit(add(radial,scale(axis,(branch.radius0-branch.radius1)/length)));
                    vertices.push(...add(center,scale(radial,radius)),...normal,0.11,0.055,0.023);
                }
            }
        }
        const leaves: LeafElement[]=[];
        for (let i=0; i<spec.leaves; i++) {
            const group=i%limbCount, slot=Math.floor(i/limbCount);
            const count=Math.ceil((spec.leaves-group)/limbCount);
            const t=(slot+0.5)/count;
            const branchIndex=group+(hasStem?1:0), branch=branches[branchIndex];
            const delta=add(branch.end,scale(branch.start,-1)), axis=unit(delta);
            const sideAxis=unit(cross(axis,Math.abs(axis[1])<0.95?[0,1,0]:[0,0,1]));
            const upAxis=cross(sideAxis,axis);
            const phase=slot*2.399963+group*0.7+(random()-0.5)*0.3;
            const outward=add(scale(sideAxis,Math.cos(phase)),scale(upAxis,Math.sin(phase)));
            // 枝先だけに集中させず、枝の途中から交互に葉を伸ばす。小枝自体が葉の付け根になる。
            const at=0.18+0.82*t;
            const base=add(branch.start,scale(delta,at));
            const forward=preset.type==='pine'?0.9:0.35;
            const lift=preset.name==='広葉樹・垂れ枝'?-0.3:0.2;
            const direction=unit(add(scale(axis,forward),add(scale(outward,0.8*parameters.opening),[0,lift,0])));
            const taper=0.75+0.25*Math.sin(Math.PI*t);
            const length=preset.length*parameters.leafSize*(0.85+random()*0.3)*taper;
            const width=preset.leafWidth*parameters.leafSize*(0.85+random()*0.3)*taper;
            const reference: Vec3=[0.35*Math.cos(phase),0.8,0.35*Math.sin(phase)];
            // 長軸に直交する基底を作り、葉ごとにロールを付けて一方向から面が消える偏りを減らす。
            const fallback=Math.abs(direction[1])<0.9?[0,1,0]:[0,0,1];
            const raw=cross(direction,reference);
            const u0=unit(Math.hypot(...raw)>1e-5?raw:cross(direction,[fallback[0],fallback[1],fallback[2]]));
            const n0=cross(u0,direction);
            const roll=(random()-0.5)*1.5+(slot%3-1)*0.25;
            const u=add(scale(u0,Math.cos(roll)),scale(n0,Math.sin(roll)));
            const normal=cross(u,direction), center=add(base,scale(direction,length/2));
            const shade=0.75+random()*0.25;
            const debug: Vec3[]=[[0.45,0.065,0.03],[0.045,0.33,0.09],[0.045,0.15,0.5],[0.45,0.26,0.025]];
            const source=parameters.debugColors?debug[variation]:preset.color;
            const color: Vec3=[source[0]*shade,source[1]*shade,source[2]*shade];
            leaves.push({center,widthAxis:u,lengthAxis:direction,halfWidth:width/2,halfLength:length/2,color,branchIndex});
            for (const [x,y] of [[width/2,0],[0,length/2],[-width/2,0],[0,-length/2]]) {
                vertices.push(...add(center,add(scale(u,x),scale(direction,y))),...normal,...color);
            }
        }
        return {preset,seed:parameters.seed,parameters:{...parameters},leaves,branches};
    });
    return {name:spec.name,verticesPerVariation:branchVertices+spec.leaves*4,variationCount:variations.length,variations,
        triangles:spec.triangles,leafCount:spec.leaves,branchCount:spec.segments,
        vertices:new Float32Array(vertices),indices:new Uint16Array(indices)};
}

leaf-library.ts

GitHub

import { BRANCH_CLASSES, createBranchCluster } from './branch-cluster.js';
import type { BranchClassSpec } from './branch-cluster.js';
export { BRANCH_CLASSES, BRANCH_PRESETS } from './branch-cluster.js';

export type Vec3 = [number, number, number];
export interface LeafElement {
    center: Vec3;
    widthAxis: Vec3;
    lengthAxis: Vec3;
    halfWidth: number;
    halfLength: number;
    color: Vec3;
    branchIndex: number;
}
export interface BranchPreset {
    readonly name: string;
    readonly type: 'broadleaf' | 'pine';
    readonly width: number;
    readonly depth: number;
    readonly rise: number;
    readonly length: number;
    readonly leafWidth: number;
    readonly color: readonly [number,number,number];
}
export interface LibraryParameters {
    seed: number;
    spread: number;
    opening: number;
    depth: number;
    leafSize: number;
    debugColors: boolean;
}
export const DEFAULT_LIBRARY_PARAMETERS: Readonly<LibraryParameters> = {
    seed:1234,spread:1,opening:1,depth:1,leafSize:1,debugColors:false
};
export interface BranchElement { start: Vec3; end: Vec3; radius0: number; radius1: number }
export interface LeafClass {
    name: string;
    verticesPerVariation: number;
    variationCount: number;
    triangles: number;
    leafCount: number;
    branchCount: number;
    variations: { preset: BranchPreset; seed: number; parameters: LibraryParameters; leaves: LeafElement[]; branches: BranchElement[] }[];
    vertices: Float32Array<ArrayBuffer>;
    indices: Uint16Array<ArrayBuffer>;
}
export interface MeshGeometry {
    verticesPerVariation: number;
    variationCount: number;
    vertices: Float32Array<ArrayBuffer>;
    indices: Uint16Array<ArrayBuffer>;
}
export interface LeafScene {
    wood?: { geometry: MeshGeometry; placements: Float32Array<ArrayBuffer> };

    placements: Float32Array<ArrayBuffer>[];
    debugLines?: Float32Array<ArrayBuffer>;
    markerVertices: Float32Array<ArrayBuffer>;
    markerIndices: Uint16Array<ArrayBuffer>;
    bounds: { min: Vec3; max: Vec3 };
}
export interface SceneParameters { markerHeight: number; leafScale: number; classIndex?: number; variationIndex?: number }
export const DEFAULT_PARAMETERS: Readonly<SceneParameters> = { markerHeight:0.75,leafScale:1 };

export function createLeafLibrary(parameters: LibraryParameters = DEFAULT_LIBRARY_PARAMETERS): LeafClass[] {
    if (!Number.isInteger(parameters.seed) || parameters.seed<0 || parameters.seed>0xffffffff) throw new Error('シードはuint32の整数にしてください');
    for (const value of [parameters.spread,parameters.opening,parameters.depth,parameters.leafSize]) {
        if (!Number.isFinite(value) || value<0.5 || value>1.5) throw new Error('枝葉の生成倍率が範囲外です');
    }
    return BRANCH_CLASSES.map((spec: BranchClassSpec)=>createBranchCluster(spec,parameters));
}

export function createLeafScene(library: readonly LeafClass[], parameters: SceneParameters): LeafScene {
    const { markerHeight, leafScale } = parameters;
    const classIndex = parameters.classIndex ?? -1;
    if (!Number.isInteger(classIndex) || classIndex < -1 || classIndex >= library.length) throw new Error('表示クラスが範囲外です');
    if (!Number.isFinite(markerHeight) || markerHeight < 0.5 || markerHeight > 3
        || !Number.isFinite(leafScale) || leafScale < 0.5 || leafScale > 2) throw new Error('配置パラメータが範囲外です');
    const variationIndex=parameters.variationIndex ?? -1;
    if (!Number.isInteger(variationIndex) || variationIndex < -1 || variationIndex > 3) throw new Error('バリエーションが範囲外です');
    const min: Vec3 = [Infinity, 0, Infinity], max: Vec3 = [-Infinity, -Infinity, -Infinity];
    const markerVertices: number[] = [], markerIndices: number[] = [];
    const include = (p: Vec3): void => {
        for (let i = 0; i < 3; i++) { min[i] = Math.min(min[i], p[i]); max[i] = Math.max(max[i], p[i]); }
    };
    const placements = library.map((leaf, classId) => {
        if (classIndex >= 0 && classId !== classIndex) return new Float32Array(0);
        const ids = variationIndex<0?Array.from({length:leaf.variationCount},(_,i)=>i):[variationIndex];
        // 1配置=48byte。位置とスケール、単位Quaternion、uint32のバリエーションID。
        const buffer = new ArrayBuffer(ids.length * 48), floats = new Float32Array(buffer), words = new Uint32Array(buffer);
        for (let instance = 0; instance < ids.length; instance++) {
            const variation = ids[instance];
            const x = (instance-(ids.length-1)/2)*4.2;
            const z = classIndex >= 0 ? 0 : (classId-1)*-5;
            floats.set([x, markerHeight, z, leafScale], instance * 12);
            floats.set([0,0,0,1],instance*12+4);
            words[instance * 12 + 8] = variation;
            for (let v = 0; v < leaf.verticesPerVariation; v++) {
                const at = (variation * leaf.verticesPerVariation + v) * 9;
                include([x + leaf.vertices[at]*leafScale, markerHeight + leaf.vertices[at+1]*leafScale, z + leaf.vertices[at+2]*leafScale]);
            }
            // 共通の目印は塊の根元を示すだけで、比較するモデルのPrim予算には含めない。
            const corners: Vec3[] = [[-0.045,0,-0.045],[0.045,0,-0.045],[0.045,markerHeight,-0.045],[-0.045,markerHeight,-0.045],
                [-0.045,0,0.045],[0.045,0,0.045],[0.045,markerHeight,0.045],[-0.045,markerHeight,0.045]];
            const faces = [[0,3,2,1],[4,5,6,7],[0,4,7,3],[1,2,6,5],[3,7,6,2],[0,1,5,4]];
            const normals: Vec3[] = [[0,0,-1],[0,0,1],[-1,0,0],[1,0,0],[0,1,0],[0,-1,0]];
            faces.forEach((face, f) => {
                const base = markerVertices.length / 9;
                for (const c of face) {
                    const p: Vec3 = [x+corners[c][0], corners[c][1], z+corners[c][2]];
                    include(p); markerVertices.push(...p, ...normals[f], 0.17, 0.09, 0.04);
                }
                markerIndices.push(base,base+1,base+2,base,base+2,base+3);
            });
        }
        return floats;
    });
    return { placements, markerVertices: new Float32Array(markerVertices), markerIndices: new Uint16Array(markerIndices), bounds: { min, max } };
}

leaf-lod.ts

GitHub

import type {LeafClass,LeafElement,Vec3,LeafScene,SceneParameters} from './leaf-library.js';
import {createLeafScene} from './leaf-library.js';
export const LEAF_LOD_BUDGETS=[
    [{branches:2,leaves:10},{branches:1,leaves:5},{branches:0,leaves:3},{branches:0,leaves:2}],
    [{branches:4,leaves:20},{branches:2,leaves:10},{branches:1,leaves:5},{branches:0,leaves:3}],
    [{branches:4,leaves:52},{branches:2,leaves:26},{branches:1,leaves:13},{branches:0,leaves:6}]
] as const;
const add=(a:Vec3,b:Vec3):Vec3=>[a[0]+b[0],a[1]+b[1],a[2]+b[2]];
const scale=(a:Vec3,s:number):Vec3=>[a[0]*s,a[1]*s,a[2]*s];
const sub=(a:Vec3,b:Vec3):Vec3=>add(a,scale(b,-1));
const dot=(a:Vec3,b:Vec3):number=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2];
const cross=(a:Vec3,b:Vec3):Vec3=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
const unit=(a:Vec3):Vec3=>scale(a,1/Math.hypot(...a));
export function leafCorners(leaf:LeafElement):Vec3[]{
    return [add(leaf.center,scale(leaf.widthAxis,leaf.halfWidth)),add(leaf.center,scale(leaf.lengthAxis,leaf.halfLength)),
        add(leaf.center,scale(leaf.widthAxis,-leaf.halfWidth)),add(leaf.center,scale(leaf.lengthAxis,-leaf.halfLength))];
}
function reduceLeaves(source:readonly LeafElement[],count:number,lod:number):LeafElement[]{
    const corners=source.map(leafCorners);
    const min:Vec3=[Infinity,Infinity,Infinity],max:Vec3=[-Infinity,-Infinity,-Infinity];
    corners.flat().forEach(p=>{for(let j=0;j<3;j++){min[j]=Math.min(min[j],p[j]);max[j]=Math.max(max[j],p[j]);}});
    const size:Vec3=[Math.max(0.01,max[0]-min[0]),Math.max(0.01,max[1]-min[1]),Math.max(0.01,max[2]-min[2])];
    const normals=source.map(leaf=>cross(leaf.widthAxis,leaf.lengthAxis));
    const distance=(i:number,j:number):number=>{
        const d=sub(source[i].center,source[j].center);
        return d.reduce((sum,v,k)=>sum+(v/size[k])**2,0)+0.12*(1-Math.abs(dot(normals[i],normals[j])));
    };
    // 同じLOD0から同じ順序で代表を選ぶため、LODをまたいでも代表葉の集合は入れ子になる。
    const selected:number[]=[];
    let largest=0;source.forEach((leaf,i)=>{if(leaf.halfWidth*leaf.halfLength>source[largest].halfWidth*source[largest].halfLength)largest=i;});
    selected.push(largest);
    while(selected.length<count){
        let best=-1,bestScore=-1;
        for(let i=0;i<source.length;i++)if(!selected.includes(i)){
            const score=Math.min(...selected.map(j=>distance(i,j)));
            if(score>bestScore){best=i;bestScore=score;}
        }
        selected.push(best);
    }
    const groups=selected.map(()=>[] as number[]);
    source.forEach((_,i)=>{
        let group=0;for(let j=1;j<selected.length;j++)if(distance(i,selected[j])<distance(i,selected[group]))group=j;
        groups[group].push(i);
    });
    return selected.map((index,group)=>{
        const original=source[index],members=groups[group].length?groups[group]:[index];
        let center:Vec3=[0,0,0],color:Vec3=[0,0,0],area=0;
        for(const i of members){const weight=source[i].halfWidth*source[i].halfLength;area+=weight;center=add(center,scale(source[i].center,weight));color=add(color,scale(source[i].color,weight));}
        // 外周を選んだ代表の位置を残しつつ、担当する葉群の重心へ少し寄せる。
        center=add(scale(original.center,0.65),scale(center,0.35/area));color=scale(color,1/area);
        let width=original.halfWidth,length=original.halfLength;
        for(const i of members)for(const p of corners[i]){
            const d=sub(p,center);width=Math.max(width,Math.abs(dot(d,original.widthAxis)));length=Math.max(length,Math.abs(dot(d,original.lengthAxis)));
        }
        const cap=[1,1.8,2.6,3.6][lod];
        width=Math.min(width,original.halfWidth*cap);length=Math.min(length,original.halfLength*cap);
        // 代表ポリゴンの拡大はLOD0の外接範囲内に制限し、輪郭の過剰な膨張を避ける。
        let fit=1;
        for(const delta of [scale(original.widthAxis,width),scale(original.widthAxis,-width),scale(original.lengthAxis,length),scale(original.lengthAxis,-length)])
            for(let j=0;j<3;j++)if(Math.abs(delta[j])>1e-8)fit=Math.min(fit,(delta[j]>0?max[j]-center[j]:center[j]-min[j])/Math.abs(delta[j]));
        return {...original,center,color,halfWidth:width*Math.max(0.01,fit),halfLength:length*Math.max(0.01,fit),branchIndex:-1};
    });
}
export function createLeafLod(source:LeafClass,classIndex:number,lod:number):LeafClass{
    if(!Number.isInteger(lod)||lod<0||lod>3||!LEAF_LOD_BUDGETS[classIndex])throw new Error('枝葉クラス/LODが範囲外です');
    if(lod===0)return source;
    const budget=LEAF_LOD_BUDGETS[classIndex][lod];
    const indices:number[]=[],vertices:number[]=[];
    for(let b=0;b<budget.branches;b++)for(let i=0;i<3;i++){const j=(i+1)%3,at=b*6;indices.push(at+i,at+j,at+3+i,at+j,at+3+j,at+3+i);}
    for(let l=0;l<budget.leaves;l++){const at=budget.branches*6+l*4;indices.push(at,at+1,at+2,at,at+2,at+3);}
    const variations=source.variations.map(variation=>{
        // 残す枝数はクラス/LODで固定。枝の選択自体は各形状の長さに応じて決めてよい。
        const branches=variation.branches.map((b,i)=>({b,i,length:Math.hypot(...sub(b.end,b.start))})).sort((a,b)=>a.i===b.i?0:a.i===0?-1:b.i===0?1:b.length-a.length).slice(0,budget.branches).map(x=>x.b);
        for(const branch of branches){
            const delta=sub(branch.end,branch.start),axis=unit(delta),u=unit(cross(Math.abs(axis[1])<0.9?[0,1,0]:[1,0,0],axis)),v=cross(axis,u);
            for(let ring=0;ring<2;ring++)for(let i=0;i<3;i++){
                const angle=i*Math.PI*2/3,radial=add(scale(u,Math.cos(angle)),scale(v,Math.sin(angle)));
                const p=add(ring===0?branch.start:branch.end,scale(radial,ring===0?branch.radius0:branch.radius1));
                const n=unit(add(radial,scale(axis,(branch.radius0-branch.radius1)/Math.hypot(...delta))));vertices.push(...p,...n,0.11,0.055,0.023);
            }
        }
        const leaves=reduceLeaves(variation.leaves,budget.leaves,lod);
        for(const leaf of leaves){const n=cross(leaf.widthAxis,leaf.lengthAxis);for(const p of leafCorners(leaf))vertices.push(...p,...n,...leaf.color);}
        return {...variation,branches,leaves};
    });
    return {...source,name:`${source.name} / LOD${lod}`,verticesPerVariation:budget.branches*6+budget.leaves*4,
        triangles:indices.length/3,branchCount:budget.branches,leafCount:budget.leaves,variations,
        vertices:new Float32Array(vertices),indices:new Uint16Array(indices)};
}
export function createLeafLods(library:readonly LeafClass[]):LeafClass[][]{
    return [0,1,2,3].map(lod=>library.map((leaf,c)=>createLeafLod(leaf,c,lod)));
}
// 横並び比較でも元の規模クラスと形状IDを維持し、LODごとに別の描画スロットへ配置する。
export function createLeafLodComparison(lods:readonly LeafClass[][],p:SceneParameters):LeafScene{
    const base=createLeafScene(lods[0],p),gap=base.bounds.max[0]-base.bounds.min[0]+2;
    const min:Vec3=[Infinity,Infinity,Infinity],max:Vec3=[-Infinity,-Infinity,-Infinity];
    const markerVertices:number[]=[],markerIndices:number[]=[];
    const placements=lods.flatMap((library,lod)=>{
        const scene=createLeafScene(library,p),offset=(lod-1.5)*gap;
        for(let j=0;j<3;j++){min[j]=Math.min(min[j],scene.bounds.min[j]+(j===0?offset:0));max[j]=Math.max(max[j],scene.bounds.max[j]+(j===0?offset:0));}
        const vertexOffset=markerVertices.length/9;
        for(let i=0;i<scene.markerVertices.length;i++)markerVertices.push(scene.markerVertices[i]+(i%9===0?offset:0));
        scene.markerIndices.forEach(i=>markerIndices.push(i+vertexOffset));
        return scene.placements.map(data=>{const copy=data.slice();for(let i=0;i<copy.length;i+=12)copy[i]+=offset;return copy;});
    });
    return {placements,markerVertices:new Float32Array(markerVertices),markerIndices:new Uint16Array(markerIndices),bounds:{min,max}};
}

main.ts

GitHub

import {createLeafLods,createLeafLodComparison} from './leaf-lod.js';
import { createTreeAssets, createTreePreviewScene } from './tree-preview.js';
import { DEFAULT_TREE_PARAMETERS, isTreeForm, TREE_PRESETS, SHAPE_CONTROLS } from './tree-model.js';
import type { ShapeKey, TreeShape } from './tree-model.js';
import { PLACEMENT_FLOATS } from './placements.js';
import { DEFAULT_PARAMETERS, DEFAULT_LIBRARY_PARAMETERS, createLeafLibrary, createLeafScene } from './leaf-library.js';
import type { LeafScene } from './leaf-library.js';
import { createPreview } from './preview.js';
import type { OrbitCamera } from './preview.js';

function element<T extends HTMLElement>(id: string, type: { new(): T }): T {
    const value = document.getElementById(id);
    if (!(value instanceof type)) throw new Error(`要素が見つかりません:${id}`);
    return value;
}
const mode=element('model-mode',HTMLSelectElement);
const leafLod=element('leaf-lod',HTMLSelectElement);
const treeCount=element('tree-count',HTMLInputElement),treeVariations=element('tree-variations',HTMLInputElement);
const woodLod=element('wood-lod',HTMLSelectElement),woodRadius=element('wood-radius',HTMLInputElement),woodTaper=element('wood-taper',HTMLInputElement);
const showLeaves=element('show-leaves',HTMLInputElement);
let treeAssets: ReturnType<typeof createTreeAssets> | undefined, treeAssetKey='';
const treeHeight=element('tree-height',HTMLInputElement), crownWidth=element('crown-width',HTMLInputElement);
const socketCount=element('socket-count',HTMLInputElement), bend=element('trunk-bend',HTMLInputElement);
const showTrunk=element('show-trunk',HTMLInputElement);
const treeForm=element('tree-form',HTMLSelectElement), majorCount=element('major-branches',HTMLInputElement);
// 制御項目を定義と共通化し、UIと生成側の範囲が食い違わないようにする。
const shapeFields=element('shape-controls',HTMLDivElement);
const shapeInputs=new Map<ShapeKey, HTMLInputElement>();
for(const key of Object.keys(SHAPE_CONTROLS) as ShapeKey[]) {
    const [title,min,max,step]=SHAPE_CONTROLS[key];
    const label=document.createElement('label'), output=document.createElement('output'), input=document.createElement('input');
    input.type='range'; input.min=String(min); input.max=String(max); input.step=String(step);
    input.value=String(DEFAULT_TREE_PARAMETERS.shape[key]); input.id=`shape-${key}`;
    label.htmlFor=input.id; label.append(title+' ',output,input); shapeFields.append(label);
    shapeInputs.set(key,input);
}
function readShape(): TreeShape {
    const result={...DEFAULT_TREE_PARAMETERS.shape};
    for(const [key,input] of shapeInputs) result[key]=input.valueAsNumber;
    return result;
}
function loadPreset(): void {
    const form=treeForm.value;
    if(!isTreeForm(form)) return;
    const preset=TREE_PRESETS[form];
    majorCount.value=String(preset.majorBranches);
    for(const [key,input] of shapeInputs) input.value=String(preset.shape[key]);
    element('preset-state',HTMLParagraphElement).textContent='プリセットの初期値';
}
const silhouette=element('silhouette',HTMLInputElement);
const canvas = element('gpu-canvas', HTMLCanvasElement);
const start = element('gpu-start', HTMLButtonElement), reset = element('camera-reset', HTMLButtonElement);
const fields = element('parameters', HTMLFieldSetElement), status = element('gpu-status', HTMLParagraphElement);
const info = element('mesh-info', HTMLParagraphElement);
const height = element('height', HTMLInputElement), scale = element('leaf-scale', HTMLInputElement);
const seed = element('seed', HTMLInputElement), spread = element('spread', HTMLInputElement);
const opening = element('opening', HTMLInputElement), debugColors = element('debug-colors', HTMLInputElement);
const classSelect = element('leaf-class', HTMLSelectElement), variationSelect = element('variation',HTMLSelectElement);
const depth = element('depth', HTMLInputElement), leafSize = element('leaf-size', HTMLInputElement);
let library = createLeafLibrary();
let leafLods=createLeafLods(library);
let libraryKey = '', shownView = 'tree:2:1';
const camera: OrbitCamera = { yaw: 0, pitch: 0.6, distance: 18, target: [0, 4, 0] };
let preview: Awaited<ReturnType<typeof createPreview>> | undefined;
let mesh: LeafScene | undefined, frame = 0, regenerateTimer = 0, session = 0;

function requestDraw(): void {
    if (frame || !preview) return;
    frame = requestAnimationFrame(() => {
        frame = 0;
        try { preview?.draw(camera,silhouette.checked); } catch (error) { fail(String(error)); }
    });
}
function frameTree(yaw = 0, pitch = 0.35): void {
    if (!mesh) return;
    const { min, max } = mesh.bounds;
    camera.target = [(min[0]+max[0])/2, (min[1]+max[1])/2, (min[2]+max[2])/2];
    camera.yaw = yaw; camera.pitch = pitch;
    const tangent = Math.tan(Math.PI/8), horizontal = tangent*canvas.clientWidth/canvas.clientHeight;
    let distance = 0;
    // 横並びでも過剰な余白を作らないよう、AABBの8隅を視点軸へ投影して収める。
    for (const x of [min[0],max[0]]) for (const y of [min[1],max[1]]) for (const z of [min[2],max[2]]) {
        const worldX=x-camera.target[0], dy=y-camera.target[1], worldZ=z-camera.target[2];
        const dx=worldX*Math.cos(yaw)-worldZ*Math.sin(yaw), dz=worldX*Math.sin(yaw)+worldZ*Math.cos(yaw);
        const up=dy*Math.cos(camera.pitch)-dz*Math.sin(camera.pitch);
        const depth=dy*Math.sin(camera.pitch)+dz*Math.cos(camera.pitch);
        distance=Math.max(distance,depth+Math.abs(dx)/horizontal,depth+Math.abs(up)/tangent);
    }
    camera.distance = Math.max(0.2,distance*1.12);
    requestDraw();
}
function regenerate(): void {
    if (!preview) return;
    const begin = performance.now();
    try {
        const parameters = { seed: seed.valueAsNumber, spread: spread.valueAsNumber,
            opening: opening.valueAsNumber, depth:depth.valueAsNumber, leafSize:leafSize.valueAsNumber, debugColors: debugColors.checked };
        const key = JSON.stringify(parameters);
        if (key !== libraryKey) {
            const generated = createLeafLibrary(parameters);
            leafLods=createLeafLods(generated);
            preview.updateLibrary(leafLods.flat());
            library = generated; libraryKey = key;
        }
        const classIndex = Number(classSelect.value);
        const treeMode=mode.value==='tree'||mode.value==='forest';
        const form=treeForm.value;
        if(!isTreeForm(form)) throw new Error('樹形が不正です');
        const count=mode.value==='forest'?treeCount.valueAsNumber:1;
        const variations=mode.value==='forest'?treeVariations.valueAsNumber:1;
        const treeParameters={seed:seed.valueAsNumber,height:treeHeight.valueAsNumber,crownWidth:crownWidth.valueAsNumber,
            socketCount:socketCount.valueAsNumber,bend:bend.valueAsNumber,classIndex,variationIndex:Number(variationSelect.value),
            showTrunk:true,form,majorBranches:majorCount.valueAsNumber,shape:readShape()};
        const woodParameters={radiusRatio:woodRadius.valueAsNumber,taper:woodTaper.valueAsNumber};
        const selectedLod=Number(leafLod.value);
        const activeLibrary=leafLods[selectedLod];
        if(treeMode) {
            const assetKey=JSON.stringify([key,treeParameters,woodParameters,variations]);
            if(!treeAssets||assetKey!==treeAssetKey) {
                treeAssets=createTreeAssets(library,treeParameters,woodParameters,variations);
                treeAssetKey=assetKey;
            }
            mesh=createTreePreviewScene(activeLibrary,treeAssets,count,Number(woodLod.value),showTrunk.checked,showLeaves.checked);
        } else if(mode.value==='lod-comparison') {
            mesh=createLeafLodComparison(leafLods,{markerHeight:height.valueAsNumber,leafScale:scale.valueAsNumber,classIndex,variationIndex:Number(variationSelect.value)});
        } else {
            mesh=createLeafScene(activeLibrary,{markerHeight:height.valueAsNumber,leafScale:scale.valueAsNumber,classIndex,variationIndex:Number(variationSelect.value)});
        }
        if(mode.value!=='lod-comparison') {
            const placements=mesh.placements;
            mesh.placements=leafLods.flatMap((_,lod)=>library.map((_,c)=>lod===selectedLod?placements[c]:new Float32Array(0)));
        }
        const drawLibrary=leafLods.flat();
        element('lod-info',HTMLParagraphElement).textContent=mode.value==='lod-comparison'?'左から LOD0 / LOD1 / LOD2 / LOD3(同じ規模クラス・同じバリエーション)':`枝葉 LOD${selectedLod}(規模クラスとは独立)`;
        preview.setScene(mesh);
        element('skeleton-info',HTMLParagraphElement).textContent=treeMode?
            `樹木 ${count}本 · 1本あたり主幹1本・主要枝${majorCount.value}本・取り付け${socketCount.value}個`:'';
        element('sharing-info',HTMLParagraphElement).textContent=mesh.wood?
            `幹:共有IB 1個 · 頂点 ${mesh.wood.geometry.verticesPerVariation}個/形状 × ${variations}形状 · Storage VB ${(mesh.wood.geometry.vertices.byteLength/1024).toFixed(1)} KiB · 配置 ${count}本(${mesh.wood.placements.byteLength} bytes)`:'';
        const memory = preview.resourceBytes;
        const counts = drawLibrary.filter((_,i)=>mesh?.placements[i].length).map(c=>`${c.name}部品1個:枝${c.branchCount*6}tri+葉${c.leafCount}枚`).join(' / ');
        element('class-info',HTMLParagraphElement).textContent = counts;
        const classes = mesh.placements.filter(p=>p.length>0).length;
        const instances = mesh.placements.reduce((sum,p)=>sum+p.length/PLACEMENT_FLOATS,0);
        const markerDraw=mesh.markerIndices.length>0?1:0;
        const lineDraw=mesh.debugLines?.length?1:0;
        // モデルのTri数はパス数を掛けず、実際に配置したクラスのIBから集計する。
        const leafTriangles=mesh.placements.reduce((sum,placements,i)=>
            sum+placements.length/PLACEMENT_FLOATS*(drawLibrary[i].indices.length/3),0)+mesh.markerIndices.length/3;
        const woodTriangles=mesh.wood?mesh.wood.geometry.indices.length/3*count:0;
        const triangles=leafTriangles+woodTriangles;
        const opaqueDraws=classes+markerDraw+(mesh.wood?1:0);
        const totalDraws=opaqueDraws*2+lineDraw+1;
        element('geometry-info',HTMLParagraphElement).textContent =
            `${treeMode?`樹木${count}本`:'表示モデル'}:合計 ${triangles.toLocaleString('ja-JP')} Tri${treeMode?`(1本平均 ${(triangles/count).toLocaleString('ja-JP',{maximumFractionDigits:1})} Tri · 幹 ${(woodTriangles/count).toLocaleString('ja-JP')} Tri/本)`:''} · 枝葉 ${instances}配置`;
        element('draw-info',HTMLParagraphElement).textContent =
            `合計 ${totalDraws} Draw/フレーム(ZPrePass ${opaqueDraws} · Forward ${opaqueDraws} · 骨格ライン ${lineDraw} · Tonemapping 1)`;
        info.textContent = `GPUバッファ ${(memory/1024).toFixed(1)} KiB · 更新 ${(performance.now()-begin).toFixed(1)} ms`;
        const view = `${mode.value}:${classIndex}:${variationSelect.value}:${treeForm.value}:${majorCount.value}:${count}`;
        if (shownView !== view) { shownView = view; frameTree(); }
        status.textContent = '表示中';
        requestDraw();
    } catch (error) { status.textContent = String(error); }
}
function updateLabels(): void {
    const treeMode=mode.value==='tree'||mode.value==='forest';
    element('tree-controls',HTMLDivElement).hidden=!treeMode;
    element('part-controls',HTMLDivElement).hidden=treeMode;
    element('forest-controls',HTMLDivElement).hidden=mode.value!=='forest';
    leafLod.disabled=mode.value==='lod-comparison';
    for(const [id,input,suffix] of [['tree-height-value',treeHeight,' m'],['crown-width-value',crownWidth,' m'],
        ['socket-count-value',socketCount,''],['trunk-bend-value',bend,' m'],['major-branches-value',majorCount,'本']] as const) {
        element(id,HTMLOutputElement).value=input.value+suffix;
    }
    for(const [id,input] of [['tree-count-value',treeCount],['tree-variations-value',treeVariations],['wood-radius-value',woodRadius],['wood-taper-value',woodTaper]] as const) element(id,HTMLOutputElement).value=input.value;
    for(const input of shapeInputs.values()) {
        const output=input.previousElementSibling;
        if(output instanceof HTMLOutputElement) output.value=input.value;
    }
    element('height-value', HTMLOutputElement).value = `${height.value} m`;
    element('scale-value', HTMLOutputElement).value = scale.value;
    element('spread-value', HTMLOutputElement).value = spread.value;
    element('opening-value', HTMLOutputElement).value = opening.value;
    element('depth-value', HTMLOutputElement).value = depth.value;
    element('leaf-size-value', HTMLOutputElement).value = leafSize.value;
}
function stop(): void {
    session++;
    cancelAnimationFrame(frame); frame = 0;
    clearTimeout(regenerateTimer);
    preview?.dispose(); preview = undefined;
    fields.disabled = true; reset.disabled = true; start.disabled = false;
}
function fail(message: string): void {
    stop(); status.textContent = message; start.textContent = '再実行';
}
start.addEventListener('click', async () => {
    stop();
    const current = session;
    start.disabled = true; status.textContent = 'WebGPUを初期化しています';
    try {
        const created = await createPreview(canvas, leafLods.flat(), message => { if (current === session) fail(message); });
        if (current !== session) { created.dispose(); return; }
        preview = created;
        fields.disabled = false; reset.disabled = false;
        regenerate(); frameTree();
        start.disabled = false; start.textContent = '再実行';
    } catch (error) { if (current === session) fail(String(error)); }
});
treeForm.addEventListener('input',loadPreset);
fields.addEventListener('input', event => {
    if(event.target!==treeForm) element('preset-state',HTMLParagraphElement).textContent='パラメータを調整中';
    updateLabels(); clearTimeout(regenerateTimer);
    // スライダーの途中値でGPUバッファを大量に作り直さない。
    regenerateTimer = window.setTimeout(regenerate, 100);
});
element('defaults', HTMLButtonElement).addEventListener('click', () => {
    clearTimeout(regenerateTimer);
    height.value = String(DEFAULT_PARAMETERS.markerHeight); scale.value = String(DEFAULT_PARAMETERS.leafScale);
    seed.value = String(DEFAULT_LIBRARY_PARAMETERS.seed); spread.value = '1'; opening.value = '1'; depth.value = '1'; leafSize.value = '1';
    debugColors.checked = false; classSelect.value = '2'; variationSelect.value = '1'; mode.value='tree';
    treeHeight.value=String(DEFAULT_TREE_PARAMETERS.height);crownWidth.value=String(DEFAULT_TREE_PARAMETERS.crownWidth);
    socketCount.value=String(DEFAULT_TREE_PARAMETERS.socketCount);bend.value=String(DEFAULT_TREE_PARAMETERS.bend);showTrunk.checked=true;
    leafLod.value='0';treeCount.value='9';treeVariations.value='4';woodLod.value='0';woodRadius.value='0.025';woodTaper.value='1';showLeaves.checked=true;
    treeForm.value=DEFAULT_TREE_PARAMETERS.form;majorCount.value=String(DEFAULT_TREE_PARAMETERS.majorBranches);silhouette.checked=false;
    loadPreset(); updateLabels(); regenerate(); frameTree();
});
reset.addEventListener('click', () => frameTree());
for (const [id,yaw,pitch] of [['view-front',0,0],['view-oblique',0.6,0.5],['view-top',0,Math.PI/2-0.01]] as const) {
    const button=element(id,HTMLButtonElement);
    button.addEventListener('click',()=>{if(preview) frameTree(yaw,pitch);});
}
let pointer: number | undefined, lastX = 0, lastY = 0;
canvas.addEventListener('pointerdown', event => {
    if (!preview || event.button !== 0 || pointer !== undefined) return;
    pointer = event.pointerId; lastX = event.clientX; lastY = event.clientY;
    canvas.setPointerCapture(pointer);
});
canvas.addEventListener('pointermove', event => {
    if (pointer !== event.pointerId) return;
    camera.yaw -= (event.clientX - lastX) * 0.008;
    camera.pitch = Math.max(-1.4, Math.min(1.4, camera.pitch + (event.clientY - lastY) * 0.008));
    lastX = event.clientX; lastY = event.clientY; requestDraw();
});
canvas.addEventListener('pointerup', event => { if (pointer === event.pointerId) { canvas.releasePointerCapture(pointer); pointer = undefined; } });
canvas.addEventListener('lostpointercapture', () => { pointer = undefined; });
canvas.addEventListener('wheel', event => {
    if (!preview) return;
    event.preventDefault();
    const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? canvas.clientHeight : 1);
    camera.distance = Math.max(0.2, Math.min(500, camera.distance * Math.exp(Math.max(-1, Math.min(1, delta * 0.001)))));
    requestDraw();
}, { passive: false });
const resize = new ResizeObserver(requestDraw);
resize.observe(canvas);
window.addEventListener('pagehide', () => { stop(); resize.disconnect(); });
window.addEventListener('pageshow', () => { resize.observe(canvas); });

updateLabels();

placements.ts

GitHub

import type { Vec3 } from './leaf-library.js';

export type Quaternion = [number, number, number, number];
export const PLACEMENT_FLOATS = 12;
export const PLACEMENT_BYTES = 48;
export interface Placement {
    position: Vec3;
    rotation: Quaternion;
    scale: number;
    variationId: number;
}

export function rotateVector(v: Vec3, q: Quaternion): Vec3 {
    const cross: Vec3 = [q[1]*v[2]-q[2]*v[1],q[2]*v[0]-q[0]*v[2],q[0]*v[1]-q[1]*v[0]];
    return [
        v[0]+2*(q[3]*cross[0]+q[1]*cross[2]-q[2]*cross[1]),
        v[1]+2*(q[3]*cross[1]+q[2]*cross[0]-q[0]*cross[2]),
        v[2]+2*(q[3]*cross[2]+q[0]*cross[1]-q[1]*cross[0])
    ];
}
export function transformPoint(v: Vec3, placement: Placement): Vec3 {
    const rotated=rotateVector(v,placement.rotation);
    return [rotated[0]*placement.scale+placement.position[0],rotated[1]*placement.scale+placement.position[1],rotated[2]*placement.scale+placement.position[2]];
}
export function packPlacements(placements: readonly Placement[]): Float32Array<ArrayBuffer> {
    const buffer=new ArrayBuffer(placements.length*PLACEMENT_BYTES);
    const floats=new Float32Array(buffer), words=new Uint32Array(buffer);
    placements.forEach((p,i)=>{
        const at=i*PLACEMENT_FLOATS;
        floats.set([...p.position,p.scale,...p.rotation],at);
        words[at+8]=p.variationId;
    });
    return floats;
}

preview.ts

GitHub

import type { LeafClass, LeafScene, Vec3, MeshGeometry } from './leaf-library.js';
import { PLACEMENT_BYTES, PLACEMENT_FLOATS } from './placements.js';

export interface OrbitCamera { yaw: number; pitch: number; distance: number; target: Vec3 }

function viewProjection(camera: OrbitCamera, aspect: number): Float32Array<ArrayBuffer> {
    const { yaw, pitch, distance, target } = camera;
    const z: Vec3 = [Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch)];
    const x: Vec3 = [Math.cos(yaw), 0, -Math.sin(yaw)];
    const y: Vec3 = [-Math.sin(yaw) * Math.sin(pitch), Math.cos(pitch), -Math.cos(yaw) * Math.sin(pitch)];
    const eye = target.map((v, i) => v + z[i] * distance);
    const dot = (v: Vec3): number => v.reduce((sum, n, i) => sum + n * eye[i], 0);
    const f = 1 / Math.tan(Math.PI / 8), sx = f / aspect;
    // near=0.05mのInfinity-far Reversed Z。
    return new Float32Array([x[0]*sx, y[0]*f, 0, -z[0], x[1]*sx, y[1]*f, 0, -z[1],
        x[2]*sx, y[2]*f, 0, -z[2], -dot(x)*sx, -dot(y)*f, 0.05, dot(z)]);
}

export async function createPreview(canvas: HTMLCanvasElement, library: readonly LeafClass[], reportError: (message: string) => void) {
    if (!navigator.gpu) throw new Error('このブラウザではWebGPUを利用できません');
    const adapter = await navigator.gpu.requestAdapter();
    if (!adapter) throw new Error('WebGPUアダプターを取得できません');
    const device = await adapter.requestDevice();
    let disposed = false;
    device.addEventListener('uncapturederror', event => reportError(event.error.message));
    void device.lost.then(info => { if (!disposed) reportError(`GPUデバイスが失われました:${info.message}`); });
    const context = canvas.getContext('webgpu');
    if (!context) { device.destroy(); throw new Error('WebGPUのCanvasを取得できません'); }
    const shader = async (name: string): Promise<GPUShaderModule> => {
        const response = await fetch(new URL(name, import.meta.url));
        if (!response.ok) throw new Error(`${name}を取得できません:${response.status}`);
        const bytes = new Uint8Array(await response.arrayBuffer());
        if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) throw new Error(`${name}にBOMがあります`);
        let code: string;
        try { code = new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
        catch { throw new Error(`${name}が不正なUTF-8です`); }
        const module = device.createShaderModule({ label: name, code });
        const info = await module.getCompilationInfo();
        const errors = info.messages.filter(message => message.type === 'error');
        if (errors.length) throw new Error(`${name}: ${errors.map(e => e.message).join('\n')}`);
        return module;
    };
    try {
        const module = await shader('preview.wgsl'), displayModule = await shader('display.wgsl');
        const format = navigator.gpu.getPreferredCanvasFormat();
        context.configure({ device, format, alphaMode: 'opaque' });
        const cameraBuffer = device.createBuffer({ size: 80, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
        const cameraLayout = device.createBindGroupLayout({ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }] });
        const layout = device.createPipelineLayout({ bindGroupLayouts: [cameraLayout] });
        const cameraGroup = device.createBindGroup({ layout: cameraLayout, entries: [{ binding: 0, resource: { buffer: cameraBuffer } }] });
        const buffers: GPUVertexBufferLayout[] = [{ arrayStride: 36, attributes: [
            { shaderLocation: 0, offset: 0, format: 'float32x3' },
            { shaderLocation: 1, offset: 12, format: 'float32x3' },
            { shaderLocation: 2, offset: 24, format: 'float32x3' }
        ] }];
        const depthPipeline = await device.createRenderPipelineAsync({ layout,
            vertex: { module, entryPoint: 'depthVertex', buffers }, primitive: { cullMode: 'back' },
            depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'greater' } });
        const forwardPipeline = await device.createRenderPipelineAsync({ layout,
            vertex: { module, entryPoint: 'surfaceVertex', buffers }, primitive: { cullMode: 'back' },
            fragment: { module, entryPoint: 'shade', targets: [{ format: 'rgba16float' }] },
            depthStencil: { format: 'depth32float', depthWriteEnabled: false, depthCompare: 'equal' } });
        const leafLayout = device.createBindGroupLayout({ entries: [
            { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
            { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
            { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } }
        ] });
        const leafPipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [cameraLayout, leafLayout] });
        const leafDepthPipeline = await device.createRenderPipelineAsync({ layout: leafPipelineLayout,
            vertex: { module, entryPoint: 'leafDepthVertex' }, primitive: { cullMode: 'none' },
            depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'greater' } });
        const leafForwardPipeline = await device.createRenderPipelineAsync({ layout: leafPipelineLayout,
            vertex: { module, entryPoint: 'leafSurfaceVertex' }, primitive: { cullMode: 'none' },
            fragment: { module, entryPoint: 'shade', targets: [{ format: 'rgba16float' }] },
            depthStencil: { format: 'depth32float', depthWriteEnabled: false, depthCompare: 'equal' } });
        // 幹も葉と同じStorage VB参照を使うが、不透明管は背面をカリングする。
        const woodDepthPipeline = await device.createRenderPipelineAsync({layout:leafPipelineLayout,
            vertex:{module,entryPoint:'leafDepthVertex'},primitive:{cullMode:'back'},
            depthStencil:{format:'depth32float',depthWriteEnabled:true,depthCompare:'greater'}});
        const woodForwardPipeline = await device.createRenderPipelineAsync({layout:leafPipelineLayout,
            vertex:{module,entryPoint:'leafSurfaceVertex'},primitive:{cullMode:'back'},
            fragment:{module,entryPoint:'shade',targets:[{format:'rgba16float'}]},
            depthStencil:{format:'depth32float',depthWriteEnabled:false,depthCompare:'equal'}});
        const linePipeline = await device.createRenderPipelineAsync({ layout,
            vertex: { module, entryPoint:'lineVertex', buffers:[{arrayStride:12,attributes:[{shaderLocation:0,offset:0,format:'float32x3'}]}] },
            primitive: { topology:'line-list' },
            fragment: { module, entryPoint:'lineColor', targets:[{format:'rgba16float'}] },
            depthStencil: {format:'depth32float',depthWriteEnabled:false,depthCompare:'greater-equal'}
        });
        const displayPipeline = await device.createRenderPipelineAsync({ layout: 'auto',
            vertex: { module: displayModule, entryPoint: 'fullscreen' },
            fragment: { module: displayModule, entryPoint: 'display', targets: [{ format }] } });
        let vertexBuffer: GPUBuffer | undefined, indexBuffer: GPUBuffer | undefined, indexCount = 0;
        let depth: GPUTexture | undefined, hdr: GPUTexture | undefined, displayGroup: GPUBindGroup | undefined;
        let width = 0, height = 0;
        let lineBuffer: GPUBuffer | undefined, lineCount=0;
        const upload = (data: Float32Array<ArrayBuffer> | Uint16Array<ArrayBuffer> | Uint32Array<ArrayBuffer>, usage: GPUBufferUsageFlags, label: string): GPUBuffer => {
            const buffer = device.createBuffer({ label, size: Math.ceil(data.byteLength/4)*4, usage: usage | GPUBufferUsage.COPY_DST });
            device.queue.writeBuffer(buffer, 0, data);
            return buffer;
        };
        // バリエーションの頂点と共有IBは初期化時に一度転送し、配置変更では作り直さない。
        const createResource = (leaf: MeshGeometry) => {
            const vertices = upload(leaf.vertices, GPUBufferUsage.STORAGE, '形状 Storage VB');
            const indices = upload(leaf.indices, GPUBufferUsage.INDEX, '共有IB');
            const parameters = upload(new Uint32Array([leaf.verticesPerVariation, 0, 0, 0]), GPUBufferUsage.UNIFORM, 'クラスの頂点数');
            const placements = device.createBuffer({ size: leaf.variationCount * PLACEMENT_BYTES, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
            const group = device.createBindGroup({ layout: leafLayout, entries: [
                { binding: 0, resource: { buffer: vertices } }, { binding: 1, resource: { buffer: placements } },
                { binding: 2, resource: { buffer: parameters } }
            ] });
            return { vertices, indices, parameters, placements, group, count: leaf.indices.length, instances: 0 };
        };
        const leafResources = library.map(createResource);
        let woodGeometry: MeshGeometry | undefined;
        let woodResource: ReturnType<typeof createResource> | undefined;
        const destroyResource=(r:ReturnType<typeof createResource>):void=>{
            r.vertices.destroy();r.indices.destroy();r.parameters.destroy();r.placements.destroy();
        };
        const setPlacements=(r:ReturnType<typeof createResource>,data:Float32Array<ArrayBuffer>):void=>{
            r.instances=data.length/PLACEMENT_FLOATS;
            if(data.byteLength>r.placements.size) {
                r.placements.destroy();
                r.placements=device.createBuffer({size:2**Math.ceil(Math.log2(data.byteLength)),usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST});
                r.group=device.createBindGroup({layout:leafLayout,entries:[
                    {binding:0,resource:{buffer:r.vertices}},{binding:1,resource:{buffer:r.placements}},{binding:2,resource:{buffer:r.parameters}}
                ]});
            }
            if(data.length)device.queue.writeBuffer(r.placements,0,data);
        };
        return {
            get resourceBytes(): number {
                return cameraBuffer.size + (vertexBuffer?.size ?? 0) + (indexBuffer?.size ?? 0) + (lineBuffer?.size ?? 0)
                    + (woodResource ? woodResource.vertices.size+woodResource.indices.size+woodResource.parameters.size+woodResource.placements.size:0)
                    + leafResources.reduce((sum, r) => sum+r.vertices.size+r.indices.size+r.parameters.size+r.placements.size, 0);
            },
            updateLibrary(library: readonly LeafClass[]): void {
                // 同じトポロジ・同じ容量のまま頂点の内容だけを置き換える。
                library.forEach((leaf,i) => device.queue.writeBuffer(leafResources[i].vertices,0,leaf.vertices));
            },
            setScene(scene: LeafScene): void {
                vertexBuffer?.destroy(); indexBuffer?.destroy();
                vertexBuffer = scene.markerVertices.length?upload(scene.markerVertices, GPUBufferUsage.VERTEX, '目印の幹VB'):undefined;
                indexBuffer = scene.markerIndices.length?upload(scene.markerIndices, GPUBufferUsage.INDEX, '目印の幹IB'):undefined;
                indexCount = scene.markerIndices.length;
                lineBuffer?.destroy();
                lineCount=(scene.debugLines?.length ?? 0)/3;
                lineBuffer=scene.debugLines?.length?upload(scene.debugLines,GPUBufferUsage.VERTEX,'主幹デバッグライン'):undefined;
                leafResources.forEach(resource=>{resource.instances=0;});
                scene.placements.forEach((data,i)=>setPlacements(leafResources[i],data));
                if(scene.wood?.geometry!==woodGeometry) {
                    if(woodResource)destroyResource(woodResource);
                    woodGeometry=scene.wood?.geometry;
                    woodResource=woodGeometry?createResource(woodGeometry):undefined;
                }
                if(woodResource&&scene.wood)setPlacements(woodResource,scene.wood.placements);

            },
            draw(camera: OrbitCamera, silhouette = false): void {
                if (disposed) return;
                const ratio = Math.min(window.devicePixelRatio, 2);
                const w = Math.min(device.limits.maxTextureDimension2D, Math.max(1, Math.round(canvas.clientWidth * ratio)));
                const h = Math.min(device.limits.maxTextureDimension2D, Math.max(1, Math.round(canvas.clientHeight * ratio)));
                if (w !== width || h !== height) {
                    width = canvas.width = w; height = canvas.height = h;
                    depth?.destroy(); hdr?.destroy();
                    depth = device.createTexture({ size: [w, h], format: 'depth32float', usage: GPUTextureUsage.RENDER_ATTACHMENT });
                    hdr = device.createTexture({ size: [w, h], format: 'rgba16float', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING });
                    displayGroup = device.createBindGroup({ layout: displayPipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: hdr.createView() }] });
                }
                if (!depth || !hdr || !displayGroup) return;
                device.queue.writeBuffer(cameraBuffer, 0, viewProjection(camera, w / h));
                device.queue.writeBuffer(cameraBuffer,64,new Uint32Array([silhouette?1:0,0,0,0]));
                const encoder = device.createCommandEncoder();
                const draw = (pass: GPURenderPassEncoder, pipeline: GPURenderPipeline, leafPipeline: GPURenderPipeline, woodPipeline: GPURenderPipeline): void => {
                    pass.setBindGroup(0, cameraGroup);
                    if (vertexBuffer && indexBuffer && indexCount) {
                        pass.setPipeline(pipeline);
                        pass.setVertexBuffer(0, vertexBuffer); pass.setIndexBuffer(indexBuffer, 'uint16');
                        pass.drawIndexed(indexCount);
                    }
                    if(woodResource?.instances) {
                        pass.setPipeline(woodPipeline);pass.setBindGroup(1,woodResource.group);
                        pass.setIndexBuffer(woodResource.indices,'uint16');
                        pass.drawIndexed(woodResource.count,woodResource.instances);
                    }
                    pass.setPipeline(leafPipeline);
                    // 同じクラスの全バリエーションを一つのIndexed Instanced Drawで描画する。
                    for (const resource of leafResources) {
                        if (!resource.instances) continue;
                        pass.setBindGroup(1, resource.group);
                        pass.setIndexBuffer(resource.indices, 'uint16');
                        pass.drawIndexed(resource.count, resource.instances);
                    }
                    pass.end();
                };
                draw(encoder.beginRenderPass({ label: 'ZPrePass', colorAttachments: [], depthStencilAttachment: {
                    view: depth.createView(), depthClearValue: 0, depthLoadOp: 'clear', depthStoreOp: 'store'
                } }), depthPipeline, leafDepthPipeline, woodDepthPipeline);
                draw(encoder.beginRenderPass({ label: 'Forward', colorAttachments: [{ view: hdr.createView(),
                    clearValue: silhouette?[0,0,0,1]:[0.015,0.025,0.035,1], loadOp: 'clear', storeOp: 'store' }],
                    depthStencilAttachment: { view: depth.createView(), depthReadOnly: true }
                }), forwardPipeline, leafForwardPipeline, woodForwardPipeline);
                if (lineBuffer && lineCount) {
                    const lines=encoder.beginRenderPass({label:'主幹デバッグ',colorAttachments:[{view:hdr.createView(),loadOp:'load',storeOp:'store'}],
                        depthStencilAttachment:{view:depth.createView(),depthReadOnly:true}});
                    lines.setPipeline(linePipeline);lines.setBindGroup(0,cameraGroup);
                    lines.setVertexBuffer(0,lineBuffer);lines.draw(lineCount);lines.end();
                }
                const post = encoder.beginRenderPass({ label: 'Tonemapping', colorAttachments: [{ view: context.getCurrentTexture().createView(), loadOp: 'clear', storeOp: 'store' }] });
                post.setPipeline(displayPipeline); post.setBindGroup(0, displayGroup); post.draw(3); post.end();
                device.queue.submit([encoder.finish()]);
            },
            dispose(): void {
                disposed = true;
                vertexBuffer?.destroy(); indexBuffer?.destroy(); cameraBuffer.destroy(); depth?.destroy(); hdr?.destroy(); lineBuffer?.destroy();
                for (const r of leafResources) destroyResource(r);
                if(woodResource)destroyResource(woodResource);
                context.unconfigure(); device.destroy();
            }
        };
    } catch (error) {
        disposed = true; context.unconfigure(); device.destroy(); throw error;
    }
}

tree-model.ts

GitHub

import type { LeafClass, LeafScene, Vec3 } from './leaf-library.js';
import { packPlacements, transformPoint } from './placements.js';
import type { Placement, Quaternion } from './placements.js';

export const TREE_FORMS = ['conical','oval','rounded','umbrella','vase','weeping'] as const;
export type TreeForm = typeof TREE_FORMS[number];
export function isTreeForm(value: string): value is TreeForm {
    return TREE_FORMS.some(form=>form===value);
}
// 角度は度、長さ・高さは樹高に対する比率。プリセット名は生成処理で参照しない。
export const SHAPE_CONTROLS = {
    trunkRatio: ['主幹の高さ比', 0.2, 1, 0.05],
    birthStart: ['主要枝の発生開始高さ', 0, 1, 0.05],
    birthEnd: ['主要枝の発生終了高さ', 0, 1, 0.05],
    birthBias: ['発生高さの偏り', 0.25, 4, 0.25],
    azimuth: ['開始方位角', -180, 180, 5],
    azimuthStep: ['方位角の間隔', 0, 180, 5],
    inclination: ['分岐角(上向きが0°)', 0, 150, 5],
    branchLength: ['主要枝の長さ比', 0.1, 1, 0.05],
    lengthFalloff: ['高い位置での短縮', 0, 0.9, 0.05],
    curvature: ['外側へ曲がる角度', -120, 120, 5],
    curveStart: ['曲がりの開始位置', 0, 0.9, 0.05],
    droop: ['先端の下垂', 0, 1, 0.05],
    irregularity: ['不規則さ', 0, 1, 0.05],
    trunkShare: ['主幹への枝葉配分', 0, 1, 0.05],
    socketStart: ['枝葉の配置開始位置', 0, 0.9, 0.05],
    socketEnd: ['枝葉の配置終了位置', 0.1, 1, 0.05],
    crownBase: ['枝葉サイズ・下部', 0.1, 1.5, 0.05],
    crownMiddle: ['枝葉サイズ・中部', 0.1, 1.5, 0.05],
    crownTop: ['枝葉サイズ・上部', 0.1, 1.5, 0.05],
    leafLift: ['枝葉の上向き補正', -1, 1, 0.05]
} as const;
export type ShapeKey = keyof typeof SHAPE_CONTROLS;
export type TreeShape = { [K in ShapeKey]: number };
const BASE_SHAPE: Readonly<TreeShape> = {
    trunkRatio:0.85,birthStart:0.35,birthEnd:0.85,birthBias:1,
    azimuth:0,azimuthStep:120,inclination:50,branchLength:0.4,lengthFalloff:0.2,
    curvature:30,curveStart:0.25,droop:0,irregularity:0.2,
    trunkShare:0.3,socketStart:0.25,socketEnd:1,
    crownBase:0.7,crownMiddle:1,crownTop:0.55,leafLift:0.3
};
export const TREE_PRESETS: Readonly<Record<TreeForm, { majorBranches: number; shape: Readonly<TreeShape> }>> = {
    conical: {majorBranches:0,shape:{...BASE_SHAPE,trunkRatio:1,socketStart:0.25,crownBase:1.3,crownMiddle:0.75,crownTop:0.1}},
    oval: {majorBranches:3,shape:{...BASE_SHAPE,trunkRatio:0.95,birthStart:0.3,birthEnd:0.75,inclination:25,branchLength:0.4,curvature:10,crownBase:0.55,crownMiddle:1,crownTop:0.4}},
    rounded: {majorBranches:3,shape:BASE_SHAPE},
    umbrella: {majorBranches:3,shape:{...BASE_SHAPE,trunkRatio:0.9,birthStart:0.8,birthEnd:0.95,inclination:85,curvature:0,branchLength:0.45,trunkShare:0.25,socketStart:0.65,crownBase:0.8,crownMiddle:1,crownTop:1,leafLift:0.5}},
    vase: {majorBranches:3,shape:{...BASE_SHAPE,trunkRatio:0.55,birthStart:0.3,birthEnd:0.65,inclination:25,branchLength:0.7,curvature:25,trunkShare:0,crownBase:0.3,crownMiddle:0.65,crownTop:1}},
    weeping: {majorBranches:3,shape:{...BASE_SHAPE,trunkRatio:0.85,birthStart:0.65,birthEnd:0.9,inclination:45,branchLength:0.55,curvature:100,curveStart:0.1,droop:0.35,trunkShare:0.15,crownBase:0.7,crownMiddle:1,crownTop:0.7,leafLift:-0.6}}
};
export interface TreeParameters {
    seed: number;
    height: number;
    crownWidth: number;
    socketCount: number;
    bend: number;
    classIndex: number;
    variationIndex: number;
    showTrunk: boolean;
    form: TreeForm;
    majorBranches: number;
    shape: Readonly<TreeShape>;
}
export const DEFAULT_TREE_PARAMETERS: Readonly<TreeParameters> = {
    seed:1234,height:8,crownWidth:6,socketCount:24,bend:0.8,classIndex:2,variationIndex:1,
    showTrunk:true,form:'rounded',majorBranches:3,shape:TREE_PRESETS.rounded.shape
};
export interface SkeletonBranch {
    parentId: number | null;
    parentSegment: number;
    parentFraction: number;
    points: Vec3[];
}
export interface TreeSocket {
    classIndex: number;
    branchId: number;
    segment: number;
    fraction: number;
    placement: Placement;
}
export interface TreeModel {
    centerline: Vec3[];
    branches: SkeletonBranch[];
    sockets: TreeSocket[];
}
function unit(v: Vec3): Vec3 {
    const n=Math.hypot(...v);
    return [v[0]/n,v[1]/n,v[2]/n];
}
function sample(points: readonly Vec3[], t: number) {
    const at=Math.max(0,Math.min(1,t))*(points.length-1);
    const segment=Math.min(points.length-2,Math.floor(at)), fraction=at-segment;
    const a=points[segment],b=points[segment+1];
    const position: Vec3=[a[0]+(b[0]-a[0])*fraction,a[1]+(b[1]-a[1])*fraction,a[2]+(b[2]-a[2])*fraction];
    return {segment,fraction,position,tangent:unit([b[0]-a[0],b[1]-a[1],b[2]-a[2]])};
}
function rotation(direction: Vec3): Quaternion {
    const v=unit(direction),w=1+v[1];
    // +Yからの最短回転。しだれ方向が真下の場合も有限値で扱う。
    if(w<1e-6) return [1,0,0,0];
    const n=Math.hypot(v[2],v[0],w);
    return [v[2]/n,0,-v[0]/n,w/n];
}
function makeSkeleton(p: TreeParameters, random: ()=>number): SkeletonBranch[] {
    const count=p.majorBranches, shape=p.shape;
    const trunkHeight=p.height*shape.trunkRatio;
    const points: Vec3[]=[];
    for(let i=0;i<=12;i++) {
        const t=i/12;
        points.push([p.bend*Math.sin(t*Math.PI*0.8)*t,trunkHeight*t,p.bend*0.45*(Math.cos(t*Math.PI*1.2)-1)*t]);
    }
    const branches: SkeletonBranch[]=[{parentId:null,parentSegment:0,parentFraction:0,points}];
    const radians=Math.PI/180;
    for(let i=0;i<count;i++) {
        // 高さ区間をべき乗で再分配する。1は均等、1超は低い側、1未満は高い側へ寄せる。
        const birth=shape.birthStart+(shape.birthEnd-shape.birthStart)*Math.pow((i+0.5)/count,shape.birthBias);
        const attachment=sample(points,birth);
        const angle=(shape.azimuth+i*shape.azimuthStep+(random()-0.5)*60*shape.irregularity)*radians;
        const length=p.height*shape.branchLength*(1-shape.lengthFalloff*birth)*(1+(random()-0.5)*0.4*shape.irregularity);
        const inclination=(shape.inclination+(random()-0.5)*30*shape.irregularity)*radians;
        const branchPoints: Vec3[]=[[...attachment.position]];
        for(let j=1;j<=8;j++) {
            const u=(j-0.5)/8;
            const curve=Math.max(0,(u-shape.curveStart)/(1-shape.curveStart));
            const theta=inclination+shape.curvature*radians*curve;
            const previous=branchPoints[j-1];
            // 接線を積分する共通曲線。下垂は先端側へ強く働き、初期分岐角とは独立する。
            branchPoints.push([
                previous[0]+Math.cos(angle)*Math.sin(theta)*length/8,
                previous[1]+Math.cos(theta)*length/8-p.height*shape.droop*((j/8)**2-((j-1)/8)**2),
                previous[2]+Math.sin(angle)*Math.sin(theta)*length/8
            ]);
        }
        branches.push({parentId:0,parentSegment:attachment.segment,parentFraction:attachment.fraction,points:branchPoints});
    }
    return branches;
}
function crownProfile(shape: Readonly<TreeShape>,u: number): number {
    // 下・中・上の3点を連続補間し、樹形名による特別扱いをしない。
    const t=Math.max(0,Math.min(1,u))*2;
    return t<1?shape.crownBase+(shape.crownMiddle-shape.crownBase)*t
        :shape.crownMiddle+(shape.crownTop-shape.crownMiddle)*(t-1);
}
export function createTreeScene(library: readonly LeafClass[], p: TreeParameters): LeafScene & { tree: TreeModel } {
    if(!Number.isInteger(p.seed)||p.seed<0||p.seed>0xffffffff
        ||!Number.isFinite(p.height)||p.height<3||p.height>20
        ||!Number.isFinite(p.crownWidth)||p.crownWidth<2||p.crownWidth>10
        ||!Number.isInteger(p.socketCount)||p.socketCount<4||p.socketCount>48
        ||!Number.isFinite(p.bend)||p.bend<0||p.bend>3
        ||!Number.isInteger(p.classIndex)||p.classIndex< -1||p.classIndex>=library.length
        ||!Number.isInteger(p.variationIndex)||p.variationIndex< -1||p.variationIndex>3
        ||!isTreeForm(p.form)||!Number.isInteger(p.majorBranches)||p.majorBranches<0||p.majorBranches>3) throw new Error('樹木パラメータが範囲外です');
    for(const key of Object.keys(SHAPE_CONTROLS) as ShapeKey[]) {
        const [,min,max]=SHAPE_CONTROLS[key], value=p.shape[key];
        if(!Number.isFinite(value)||value<min||value>max) throw new Error(`樹形パラメータが範囲外です:${key}`);
    }
    if(p.shape.birthStart>p.shape.birthEnd||p.shape.socketStart>p.shape.socketEnd) throw new Error('開始位置は終了位置以下にしてください');
    let state=p.seed;
    const random=(): number=>{state=(Math.imul(state,1664525)+1013904223)>>>0;return state/4294967296;};
    const branches=makeSkeleton(p,random),centerline=branches[0].points;
    const lists: Placement[][]=library.map(()=>[]),sockets: TreeSocket[]=[];
    const extents=library.map(c=>new Array<number>(c.variationCount).fill(0));
    const majorCount=branches.length-1;
    const coreCount=majorCount?Math.round(p.socketCount*p.shape.trunkShare):p.socketCount;
    for(let i=0;i<p.socketCount;i++) {
        // 主幹への配分と枝上の配置区間は、どのプリセットでも同じ規則を使う。
        const core=i<coreCount;
        const distributed=i-coreCount, remaining=p.socketCount-coreCount;
        const branchId=core?0:majorCount?1+distributed%majorCount:0;
        const slot=core?i:majorCount?Math.floor(distributed/majorCount):i;
        const count=core?coreCount:majorCount?Math.ceil((remaining-(branchId-1))/majorCount):p.socketCount;
        const u=(slot+0.5)/count;
        const t=p.shape.socketStart+(p.shape.socketEnd-p.shape.socketStart)*u;
        const attachment=sample(branches[branchId].points,t);
        const heightFraction=attachment.position[1]/p.height;
        const classIndex=p.classIndex<0?Math.max(0,Math.min(2,Math.floor((1-heightFraction)*4))):p.classIndex;
        const variationId=p.variationIndex<0?Math.floor(random()*library[classIndex].variationCount):p.variationIndex;
        let extent=extents[classIndex][variationId];
        if(extent===0) {
            const c=library[classIndex];
            for(let v=0;v<c.verticesPerVariation;v++) {
                const at=(variationId*c.verticesPerVariation+v)*9;
                extent=Math.max(extent,Math.hypot(c.vertices[at],c.vertices[at+1],c.vertices[at+2]));
            }
            extents[classIndex][variationId]=extent;
        }
        const azimuth=i*2.399963+(random()-0.5)*0.2;
        const tangent=attachment.tangent;
        const radial: Vec3=[Math.cos(azimuth),0,Math.sin(azimuth)];
        const radialWeight=branchId===0?1:0.4;
        const direction: Vec3=[
            tangent[0]*0.8+radial[0]*radialWeight,
            tangent[1]*0.8+p.shape.leafLift,
            tangent[2]*0.8+radial[2]*radialWeight
        ];
        const envelope=crownProfile(p.shape,u);
        // 骨格の張り出しと部品サイズを分離し、取り付け点を骨格上に保つ。
        const radius=p.crownWidth*0.3*envelope*(1+(random()-0.5)*0.3*p.shape.irregularity);
        const placement: Placement={position:attachment.position,rotation:rotation(direction),scale:radius/Math.max(extent,0.01),variationId};
        lists[classIndex].push(placement);
        sockets.push({classIndex,branchId,segment:attachment.segment,fraction:attachment.fraction,placement});
    }
    const min: Vec3=[0,0,0],max: Vec3=[0,0,0];
    const include=(v: Vec3): void=>{for(let i=0;i<3;i++){min[i]=Math.min(min[i],v[i]);max[i]=Math.max(max[i],v[i]);}};
    for(const branch of branches) branch.points.forEach(include);
    lists.forEach((placements,c)=>placements.forEach(placement=>{
        for(let i=0;i<library[c].verticesPerVariation;i++) {
            const at=(placement.variationId*library[c].verticesPerVariation+i)*9;
            include(transformPoint([library[c].vertices[at],library[c].vertices[at+1],library[c].vertices[at+2]],placement));
        }
    }));
    const lines: number[]=[];
    if(p.showTrunk) for(const branch of branches) for(let i=0;i<branch.points.length-1;i++) lines.push(...branch.points[i],...branch.points[i+1]);
    return {placements:lists.map(packPlacements),markerVertices:new Float32Array(0),markerIndices:new Uint16Array(0),
        debugLines:new Float32Array(lines),bounds:{min,max},tree:{centerline,branches,sockets}};
}

tree-preview.ts

GitHub

import { createTreeScene } from './tree-model.js';
import type { TreeParameters } from './tree-model.js';
import { createWoodGeometry } from './wood-mesh.js';
import type { WoodParameters } from './wood-mesh.js';
import type { LeafClass, LeafScene, Vec3 } from './leaf-library.js';
import { packPlacements, transformPoint } from './placements.js';
import type { Placement, Quaternion } from './placements.js';

export function createTreeAssets(library:readonly LeafClass[],p:TreeParameters,wood:WoodParameters,variations:number) {
    if(!Number.isInteger(variations)||variations<1||variations>4)throw new Error('樹木の形状数は1〜4です');
    const trees=Array.from({length:variations},(_,i)=>createTreeScene(library,{...p,seed:(p.seed+Math.imul(i,2654435761))>>>0,showTrunk:true}));
    return {trees,wood:Array.from({length:3},(_,lod)=>createWoodGeometry(trees.map(s=>s.tree),p.height,wood,lod)),spacing:Math.max(p.height,p.crownWidth)*1.3};
}
export function createTreePreviewScene(library:readonly LeafClass[],assets:ReturnType<typeof createTreeAssets>,count:number,lod:number,showLines:boolean,showLeaves:boolean):LeafScene {
    if(!Number.isInteger(count)||count<1||count>100||!Number.isInteger(lod)||!assets.wood[lod])throw new Error('樹木プレビューの設定が範囲外です');
    const leafLists:Placement[][]=library.map(()=>[]),woodList:Placement[]=[],lines:number[]=[];
    const min:Vec3=[Infinity,Infinity,Infinity],max:Vec3=[-Infinity,-Infinity,-Infinity];
    const include=(v:Vec3):void=>{for(let j=0;j<3;j++){min[j]=Math.min(min[j],v[j]);max[j]=Math.max(max[j],v[j]);}};
    const geometry=assets.wood[lod],columns=Math.ceil(Math.sqrt(count));
    for(let i=0;i<count;i++) {
        const variationId=i%assets.trees.length,tree=assets.trees[variationId];
        const angle=count===1?0:i*2.399963;
        const world:Placement={position:[((i%columns)-(columns-1)/2)*assets.spacing,0,(Math.floor(i/columns)-(Math.ceil(count/columns)-1)/2)*assets.spacing],
            rotation:[0,Math.sin(angle/2),0,Math.cos(angle/2)],scale:1,variationId};
        woodList.push(world);
        // 葉部品の形状は共有。初版では取り付け配置だけをCPUでワールド空間へ展開する。
        if(showLeaves)for(const socket of tree.tree.sockets) {
            const local=socket.placement,a=world.rotation,b=local.rotation;
            const rotation:Quaternion=[a[3]*b[0]+a[0]*b[3]+a[1]*b[2]-a[2]*b[1],a[3]*b[1]-a[0]*b[2]+a[1]*b[3]+a[2]*b[0],a[3]*b[2]+a[0]*b[1]-a[1]*b[0]+a[2]*b[3],a[3]*b[3]-a[0]*b[0]-a[1]*b[1]-a[2]*b[2]];
            leafLists[socket.classIndex].push({...local,position:transformPoint(local.position,world),rotation});
        }
        for(let v=0;v<geometry.verticesPerVariation;v++) {
            const at=(variationId*geometry.verticesPerVariation+v)*9;
            include(transformPoint([geometry.vertices[at],geometry.vertices[at+1],geometry.vertices[at+2]],world));
        }
        if(showLeaves)for(const x of [tree.bounds.min[0],tree.bounds.max[0]])for(const y of [tree.bounds.min[1],tree.bounds.max[1]])for(const z of [tree.bounds.min[2],tree.bounds.max[2]])include(transformPoint([x,y,z],world));
        if(showLines)for(const branch of tree.tree.branches)for(let j=0;j<branch.points.length-1;j++)lines.push(...transformPoint(branch.points[j],world),...transformPoint(branch.points[j+1],world));
    }
    return {wood:{geometry,placements:packPlacements(woodList)},placements:leafLists.map(packPlacements),debugLines:new Float32Array(lines),
        markerVertices:new Float32Array(0),markerIndices:new Uint16Array(0),bounds:{min,max}};
}

wood-mesh.ts

GitHub

import type { MeshGeometry, Vec3 } from './leaf-library.js';
import type { TreeModel } from './tree-model.js';

export const WOOD_LODS = [
    { sides:8, trunkSegments:12, branchSegments:8 },
    { sides:6, trunkSegments:6, branchSegments:4 },
    { sides:4, trunkSegments:3, branchSegments:2 }
] as const;
export interface WoodParameters { radiusRatio: number; taper: number }
export const DEFAULT_WOOD_PARAMETERS: Readonly<WoodParameters> = {radiusRatio:0.025,taper:1};
const dot=(a:Vec3,b:Vec3):number=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2];
const cross=(a:Vec3,b:Vec3):Vec3=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
const unit=(a:Vec3):Vec3=>{const n=Math.hypot(...a);if(n<1e-10)throw new Error('骨格の線分長が不足しています');return [a[0]/n,a[1]/n,a[2]/n];};
function sample(points:readonly Vec3[],t:number):Vec3 {
    const at=t*(points.length-1),i=Math.min(points.length-2,Math.floor(at)),f=at-i;
    const a=points[i],b=points[i+1];
    return [a[0]+(b[0]-a[0])*f,a[1]+(b[1]-a[1])*f,a[2]+(b[2]-a[2])*f];
}
export function createWoodGeometry(trees:readonly TreeModel[],height:number,p:Readonly<WoodParameters>,lod:number):MeshGeometry {
    const spec=WOOD_LODS[lod];
    if(!spec||!trees.length||!Number.isFinite(height)||height<=0
        ||!Number.isFinite(p.radiusRatio)||p.radiusRatio<0.01||p.radiusRatio>0.08
        ||!Number.isFinite(p.taper)||p.taper<0.5||p.taper>2)throw new Error('幹メッシュの設定が範囲外です');
    const branchCount=trees[0].branches.length;
    if(trees.some(tree=>tree.branches.length!==branchCount))throw new Error('共有IBの骨格枝数が一致しません');
    const indices:number[]=[],allVertices:number[]=[];
    let verticesPerVariation=0;
    for(const [variation,tree] of trees.entries()) {
        const vertices:number[]=[];
        for(const [branchId,branch] of tree.branches.entries()) {
            const segments=branchId===0?spec.trunkSegments:spec.branchSegments;
            const centers=Array.from({length:segments+1},(_,i)=>sample(branch.points,i/segments));
            const base=vertices.length/9;
            const parent=branch.parentId===null?undefined:tree.branches[branch.parentId];
            const birth=parent?(branch.parentSegment+branch.parentFraction)/(parent.points.length-1):0;
            const rootRadius=height*p.radiusRatio*(branchId===0?1:0.6*Math.max(0.15,(1-birth)**p.taper));
            let previousTangent:Vec3=[0,1,0],normal:Vec3=[1,0,0];
            for(let ring=0;ring<=segments;ring++) {
                const a=centers[Math.max(0,ring-1)],b=centers[Math.min(segments,ring+1)];
                const tangent=unit([b[0]-a[0],b[1]-a[1],b[2]-a[2]]);
                // 最短回転で断面フレームを運ぶ。初期接線の反転時も安定した軸を選ぶ。
                const axis=cross(previousTangent,tangent),cos=dot(previousTangent,tangent);
                if(cos> -0.9999) {
                    const v=cross(axis,normal),w=cross(axis,v);
                    normal=unit([normal[0]+v[0]+w[0]/(1+cos),normal[1]+v[1]+w[1]/(1+cos),normal[2]+v[2]+w[2]/(1+cos)]);
                } else {
                    const reference:Vec3=Math.abs(tangent[0])<0.8?[1,0,0]:[0,0,1];
                    normal=unit(cross(tangent,reference));
                }
                previousTangent=tangent;
                const binormal=cross(tangent,normal),t=ring/segments;
                const radius=rootRadius*(0.08+0.92*(1-t)**p.taper);
                // テーパーの法線補正は、このリング前後の半径差と中心線の距離から求める。
                const radiusAt=(ring:number):number=>rootRadius*(0.08+0.92*(1-ring/segments)**p.taper);
                const slope=(radiusAt(Math.min(segments,ring+1))-radiusAt(Math.max(0,ring-1)))
                    /Math.max(0.001,Math.hypot(b[0]-a[0],b[1]-a[1],b[2]-a[2]));
                for(let side=0;side<spec.sides;side++) {
                    const angle=side/spec.sides*Math.PI*2,c=Math.cos(angle),s=Math.sin(angle);
                    const radial:Vec3=[normal[0]*c+binormal[0]*s,normal[1]*c+binormal[1]*s,normal[2]*c+binormal[2]*s];
                    const n=unit([radial[0]-tangent[0]*slope,radial[1]-tangent[1]*slope,radial[2]-tangent[2]*slope]);
                    vertices.push(centers[ring][0]+radial[0]*radius,centers[ring][1]+radial[1]*radius,centers[ring][2]+radial[2]*radius,
                        ...n,0.19,0.095,0.038);
                }
            }
            if(variation===0)for(let ring=0;ring<segments;ring++)for(let side=0;side<spec.sides;side++) {
                const a=base+ring*spec.sides+side,b=base+ring*spec.sides+(side+1)%spec.sides;
                indices.push(a,b,a+spec.sides,b,b+spec.sides,a+spec.sides);
            }
            // 端面は独立法線。枝の根元は主幹内部へ埋まり、接合の溶接は行わない。
            for(const end of [0,segments]) {
                const at=vertices.length/9,center=centers[end],neighbor=centers[end===0?1:segments-1];
                const capNormal=unit([center[0]-neighbor[0],center[1]-neighbor[1],center[2]-neighbor[2]]);
                vertices.push(...center,...capNormal,0.19,0.095,0.038);
                for(let side=0;side<spec.sides;side++) {
                    const source=(base+end*spec.sides+side)*9;
                    vertices.push(...vertices.slice(source,source+3),...capNormal,0.19,0.095,0.038);
                }
                if(variation===0)for(let side=0;side<spec.sides;side++) {
                    const a=at+1+side,b=at+1+(side+1)%spec.sides;
                    indices.push(at,...(end===0?[b,a]:[a,b]));
                }
            }
        }
        if(variation===0)verticesPerVariation=vertices.length/9;
        if(vertices.length/9!==verticesPerVariation||verticesPerVariation>65535)throw new Error('幹の共有トポロジが不正です');
        allVertices.push(...vertices);
    }
    return {verticesPerVariation,variationCount:trees.length,vertices:new Float32Array(allVertices),indices:new Uint16Array(indices)};
}