mirror of
https://github.com/beestat/app.git
synced 2026-02-26 13:10:23 -05:00
857 lines
32 KiB
JavaScript
857 lines
32 KiB
JavaScript
/**
|
|
* Scene methods split from scene.js.
|
|
*/
|
|
|
|
|
|
/**
|
|
* Build a radial alpha texture used for soft tree-ground contact decals.
|
|
*
|
|
* @return {?THREE.CanvasTexture}
|
|
*/
|
|
beestat.component.scene.prototype.create_tree_ground_contact_texture_ = function() {
|
|
const size = 64;
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
const context = canvas.getContext('2d');
|
|
|
|
if (context === null) {
|
|
return null;
|
|
}
|
|
|
|
const gradient = context.createRadialGradient(
|
|
size / 2,
|
|
size / 2,
|
|
size * 0.06,
|
|
size / 2,
|
|
size / 2,
|
|
size / 2
|
|
);
|
|
gradient.addColorStop(0, 'rgba(0, 0, 0, 0.48)');
|
|
gradient.addColorStop(0.45, 'rgba(0, 0, 0, 0.2)');
|
|
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
|
|
|
context.clearRect(0, 0, size, size);
|
|
context.fillStyle = gradient;
|
|
context.fillRect(0, 0, size, size);
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.generateMipmaps = true;
|
|
texture.needsUpdate = true;
|
|
|
|
return texture;
|
|
};
|
|
|
|
|
|
/**
|
|
* Get shared material for soft trunk-to-ground blending.
|
|
*
|
|
* @return {THREE.MeshBasicMaterial}
|
|
*/
|
|
beestat.component.scene.prototype.get_tree_ground_contact_material_ = function() {
|
|
if (this.tree_ground_contact_material_ !== undefined) {
|
|
return this.tree_ground_contact_material_;
|
|
}
|
|
|
|
const texture = this.create_tree_ground_contact_texture_();
|
|
this.tree_ground_contact_material_ = new THREE.MeshBasicMaterial({
|
|
'color': 0x1a1208,
|
|
'map': texture,
|
|
'transparent': true,
|
|
'opacity': 0.3,
|
|
'depthWrite': false,
|
|
'polygonOffset': true,
|
|
'polygonOffsetFactor': -1,
|
|
'polygonOffsetUnits': -2,
|
|
'side': THREE.DoubleSide
|
|
});
|
|
|
|
return this.tree_ground_contact_material_;
|
|
};
|
|
|
|
|
|
/**
|
|
* Add stylized root collar + soft contact shadow to blend tree base into terrain.
|
|
*
|
|
* @param {THREE.Group} tree
|
|
* @param {number} trunk_radius
|
|
* @param {number} trunk_color
|
|
*/
|
|
beestat.component.scene.prototype.add_tree_ground_contact_ = function(tree, trunk_radius, trunk_color) {
|
|
const base_radius = Math.max(0.8, trunk_radius);
|
|
const collar_height = Math.max(1.8, base_radius * 0.8);
|
|
const collar_geometry = new THREE.CylinderGeometry(
|
|
Math.max(1.1, base_radius * 1.5),
|
|
Math.max(1.6, base_radius * 2.2),
|
|
collar_height,
|
|
7
|
|
);
|
|
collar_geometry.rotateX(-Math.PI / 2);
|
|
const collar_color = new THREE.Color(trunk_color);
|
|
collar_color.multiplyScalar(0.84 + (Math.random() * 0.08));
|
|
const collar = new THREE.Mesh(
|
|
collar_geometry,
|
|
new THREE.MeshStandardMaterial({
|
|
'color': collar_color,
|
|
'roughness': 1.0,
|
|
'metalness': 0.0
|
|
})
|
|
);
|
|
collar.position.z = (collar_height / 2) - (Math.max(0.2, base_radius * 0.08));
|
|
collar.rotation.z = Math.random() * Math.PI * 2;
|
|
collar.castShadow = true;
|
|
collar.receiveShadow = false;
|
|
collar.userData.is_environment = true;
|
|
tree.add(collar);
|
|
|
|
const contact_radius = Math.max(2, base_radius * 2.05);
|
|
const contact_geometry = new THREE.CircleGeometry(contact_radius, 14);
|
|
const contact = new THREE.Mesh(
|
|
contact_geometry,
|
|
this.get_tree_ground_contact_material_()
|
|
);
|
|
contact.position.z = 0.06;
|
|
contact.castShadow = false;
|
|
contact.receiveShadow = false;
|
|
contact.userData.is_environment = true;
|
|
tree.add(contact);
|
|
};
|
|
|
|
|
|
/**
|
|
* Create a low-poly conical tree with slight procedural variation.
|
|
*
|
|
* @param {number} height Total tree height.
|
|
* @param {number} max_diameter Maximum foliage diameter.
|
|
* @param {boolean} has_foliage Whether foliage should be rendered.
|
|
*
|
|
* @return {THREE.Group}
|
|
*/
|
|
beestat.component.scene.prototype.create_conical_tree_ = function(height, max_diameter, has_foliage) {
|
|
const clamped_height = Math.max(40, height || 120);
|
|
const clamped_diameter = Math.max(18, max_diameter || 48);
|
|
const tree = new THREE.Group();
|
|
tree.userData.is_environment = true;
|
|
tree.userData.is_tree = true;
|
|
|
|
const trunk_height_ratio = 0.2 + (Math.random() * 0.08);
|
|
const trunk_height = clamped_height * trunk_height_ratio;
|
|
const trunk_radius_top = Math.max(1.2, clamped_diameter * (0.045 + (Math.random() * 0.015)));
|
|
const trunk_radius_bottom = trunk_radius_top * (1.25 + (Math.random() * 0.2));
|
|
const trunk_geometry = new THREE.CylinderGeometry(
|
|
trunk_radius_top,
|
|
trunk_radius_bottom,
|
|
trunk_height,
|
|
6
|
|
);
|
|
trunk_geometry.rotateX(-Math.PI / 2);
|
|
const trunk_material = new THREE.MeshStandardMaterial({
|
|
'color': 0x5d4226,
|
|
'roughness': 0.9,
|
|
'metalness': 0.0
|
|
});
|
|
const trunk = new THREE.Mesh(trunk_geometry, trunk_material);
|
|
trunk.position.z = -(trunk_height / 2) + Math.max(0.6, trunk_radius_bottom * 0.1);
|
|
trunk.castShadow = true;
|
|
trunk.receiveShadow = true;
|
|
trunk.userData.is_environment = true;
|
|
tree.add(trunk);
|
|
this.add_tree_ground_contact_(tree, trunk_radius_bottom, 0x5d4226);
|
|
|
|
if (has_foliage === false) {
|
|
return tree;
|
|
}
|
|
|
|
const clamp01 = function(value) {
|
|
return Math.max(0, Math.min(1, value));
|
|
};
|
|
|
|
const crown_height_target = Math.max(10, clamped_height - trunk_height);
|
|
const base_foliage_color = new THREE.Color(0x2f7d2d);
|
|
const base_hsl = {};
|
|
base_foliage_color.getHSL(base_hsl);
|
|
const tree_foliage_color = new THREE.Color().setHSL(
|
|
clamp01(base_hsl.h + ((Math.random() - 0.5) * 0.03)),
|
|
clamp01(base_hsl.s + ((Math.random() - 0.5) * 0.08)),
|
|
clamp01(base_hsl.l + ((Math.random() - 0.5) * 0.08))
|
|
);
|
|
const foliage_material = new THREE.MeshStandardMaterial({
|
|
'color': tree_foliage_color,
|
|
'roughness': 0.85,
|
|
'metalness': 0.0,
|
|
'flatShading': true
|
|
});
|
|
const max_tilt_radians = Math.PI * 0.02;
|
|
const max_segments = 10;
|
|
let previous_apex_height = null;
|
|
let previous_radius = null;
|
|
let previous_segment_height = null;
|
|
|
|
for (let i = 0; i < max_segments; i++) {
|
|
let segment_height;
|
|
let segment_base_height;
|
|
if (i === 0) {
|
|
segment_height = crown_height_target * (0.34 + (Math.random() * 0.14));
|
|
segment_base_height = trunk_height * (0.9 + (Math.random() * 0.08));
|
|
} else {
|
|
segment_height = previous_segment_height * (0.94 + (Math.random() * 0.02));
|
|
segment_height = Math.max(8, segment_height);
|
|
const overlap = previous_segment_height * (0.5 + ((Math.random() - 0.5) * 0.06));
|
|
segment_base_height = previous_apex_height - overlap;
|
|
}
|
|
|
|
const progress = Math.max(
|
|
0,
|
|
Math.min(1, (segment_base_height - trunk_height) / Math.max(1, crown_height_target))
|
|
);
|
|
const radius_variation = 0.9 + (Math.random() * 0.16);
|
|
let radius = Math.max(
|
|
2,
|
|
((clamped_diameter / 2) * (1 - (progress * 0.75))) * radius_variation
|
|
);
|
|
if (previous_radius !== null) {
|
|
const overlap = previous_apex_height - segment_base_height;
|
|
const previous_overlap_ratio = Math.max(
|
|
0,
|
|
Math.min(1, overlap / previous_segment_height)
|
|
);
|
|
const previous_overlap_radius = previous_radius * previous_overlap_ratio;
|
|
const min_radius_for_overlap = previous_overlap_radius * (1.06 + (Math.random() * 0.05));
|
|
const max_radius_for_taper = previous_radius * (0.94 + (Math.random() * 0.03));
|
|
radius = Math.max(radius, min_radius_for_overlap);
|
|
radius = Math.min(radius, max_radius_for_taper);
|
|
if (radius < min_radius_for_overlap) {
|
|
radius = min_radius_for_overlap;
|
|
}
|
|
}
|
|
radius = Math.max(2, radius);
|
|
|
|
const foliage_geometry = new THREE.ConeGeometry(radius, segment_height, 6);
|
|
foliage_geometry.rotateX(-Math.PI / 2);
|
|
const cone_material = foliage_material.clone();
|
|
cone_material.color.offsetHSL(
|
|
(Math.random() - 0.5) * 0.01,
|
|
(Math.random() - 0.5) * 0.03,
|
|
(Math.random() - 0.5) * 0.03
|
|
);
|
|
const foliage_mesh = new THREE.Mesh(foliage_geometry, cone_material);
|
|
foliage_mesh.position.z = -(segment_base_height + (segment_height / 2));
|
|
const tilt_direction = Math.random() * Math.PI * 2;
|
|
const tilt_amount = Math.random() * max_tilt_radians;
|
|
foliage_mesh.rotation.x = Math.cos(tilt_direction) * tilt_amount;
|
|
foliage_mesh.rotation.y = Math.sin(tilt_direction) * tilt_amount;
|
|
foliage_mesh.rotation.z = (Math.random() - 0.5) * 0.2;
|
|
foliage_mesh.castShadow = true;
|
|
foliage_mesh.receiveShadow = false;
|
|
foliage_mesh.userData.is_environment = true;
|
|
foliage_mesh.userData.is_tree_foliage = true;
|
|
foliage_mesh.userData.base_tree_foliage_color = foliage_mesh.material.color.getHex();
|
|
tree.add(foliage_mesh);
|
|
|
|
previous_apex_height = segment_base_height + segment_height;
|
|
previous_radius = radius;
|
|
previous_segment_height = segment_height;
|
|
|
|
if (previous_apex_height >= clamped_height) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return tree;
|
|
};
|
|
|
|
|
|
/**
|
|
* Sample XY offset from a stick curve at a height measured from the stick base.
|
|
*
|
|
* @param {{controls: Array<{x: number, y: number}>, height: number}} curve
|
|
* @param {number} height_from_base
|
|
*
|
|
* @return {{x: number, y: number}}
|
|
*/
|
|
beestat.component.scene.prototype.sample_stick_curve_offset_ = function(curve, height_from_base) {
|
|
if (
|
|
curve === undefined ||
|
|
curve.controls === undefined ||
|
|
curve.controls.length < 2 ||
|
|
curve.height === undefined ||
|
|
curve.height <= 0
|
|
) {
|
|
return {'x': 0, 'y': 0};
|
|
}
|
|
|
|
const t = Math.max(0, Math.min(1, height_from_base / curve.height));
|
|
const scaled = t * (curve.controls.length - 1);
|
|
const index = Math.floor(scaled);
|
|
const next_index = Math.min(curve.controls.length - 1, index + 1);
|
|
const blend = scaled - index;
|
|
|
|
return {
|
|
'x': THREE.MathUtils.lerp(curve.controls[index].x, curve.controls[next_index].x, blend),
|
|
'y': THREE.MathUtils.lerp(curve.controls[index].y, curve.controls[next_index].y, blend)
|
|
};
|
|
};
|
|
|
|
|
|
/**
|
|
* Create a low-poly tapered stick mesh with slight bend.
|
|
*
|
|
* @param {object} config
|
|
*
|
|
* @return {{mesh: THREE.Mesh, curve: {controls: Array<{x: number, y: number}>, height: number}, radius_top: number, radius_bottom: number, height: number}}
|
|
*/
|
|
beestat.component.scene.prototype.create_stick_mesh_ = function(config) {
|
|
const height = Math.max(1, config.height || 10);
|
|
const radius_bottom = Math.max(0.15, config.radius_bottom || 1);
|
|
const taper_end_ratio = config.taper_end_ratio === undefined
|
|
? null
|
|
: Math.max(0, Math.min(1, config.taper_end_ratio));
|
|
const taper_max_ratio = config.taper_max_ratio === undefined
|
|
? null
|
|
: Math.max(0, Math.min(1, config.taper_max_ratio));
|
|
const resolved_top_ratio = taper_max_ratio === null
|
|
? taper_end_ratio
|
|
: (1 - taper_max_ratio);
|
|
const radius_top = Math.max(
|
|
0,
|
|
resolved_top_ratio === null
|
|
? (config.radius_top === undefined ? (radius_bottom * 0.7) : config.radius_top)
|
|
: (radius_bottom * resolved_top_ratio)
|
|
);
|
|
const radial_segments = Math.max(3, config.radial_segments || 7);
|
|
const segments = Math.max(1, Math.round(height / 12));
|
|
const control_count = Math.max(2, config.control_count || 5);
|
|
const max_drift = Math.max(0, config.max_drift || 0);
|
|
const direction_jitter = config.direction_jitter || (radius_bottom * 0.15);
|
|
const straight_start_ratio = Math.max(0, Math.min(0.9, config.straight_start_ratio || 0));
|
|
const taper_start_ratio = Math.max(0, Math.min(0.95, config.taper_start_ratio || 0));
|
|
|
|
const controls = [{'x': 0, 'y': 0}];
|
|
let drift_x = 0;
|
|
let drift_y = 0;
|
|
for (let i = 1; i < control_count; i++) {
|
|
const progress = i / (control_count - 1);
|
|
drift_x += (Math.random() - 0.5) * direction_jitter;
|
|
drift_y += (Math.random() - 0.5) * direction_jitter;
|
|
const drift_length = Math.sqrt((drift_x * drift_x) + (drift_y * drift_y));
|
|
const drift_limit = max_drift * progress;
|
|
if (drift_length > drift_limit && drift_length > 0) {
|
|
const scale = drift_limit / drift_length;
|
|
drift_x *= scale;
|
|
drift_y *= scale;
|
|
}
|
|
controls.push({'x': drift_x, 'y': drift_y});
|
|
}
|
|
|
|
const curve = {
|
|
'controls': controls,
|
|
'height': height
|
|
};
|
|
|
|
const geometry = new THREE.CylinderGeometry(
|
|
radius_bottom,
|
|
radius_bottom,
|
|
height,
|
|
radial_segments,
|
|
segments
|
|
);
|
|
geometry.rotateX(-Math.PI / 2);
|
|
|
|
const position = geometry.attributes.position;
|
|
for (let i = 0; i < position.count; i++) {
|
|
const vertex_z = position.getZ(i);
|
|
const height_from_base = (height / 2) - vertex_z;
|
|
const height_ratio = Math.max(0, Math.min(1, height_from_base / height));
|
|
const taper_progress = height_ratio <= taper_start_ratio
|
|
? 0
|
|
: (height_ratio - taper_start_ratio) / Math.max(0.0001, 1 - taper_start_ratio);
|
|
const target_radius = THREE.MathUtils.lerp(radius_bottom, radius_top, taper_progress);
|
|
const taper_scale = target_radius / radius_bottom;
|
|
position.setX(i, position.getX(i) * taper_scale);
|
|
position.setY(i, position.getY(i) * taper_scale);
|
|
|
|
const offset = this.sample_stick_curve_offset_(curve, height_from_base);
|
|
if (straight_start_ratio > 0) {
|
|
const straight_height = height * straight_start_ratio;
|
|
const bend_blend = height_from_base <= straight_height
|
|
? (height_from_base / Math.max(0.0001, straight_height))
|
|
: 1;
|
|
position.setX(i, position.getX(i) + (offset.x * bend_blend));
|
|
position.setY(i, position.getY(i) + (offset.y * bend_blend));
|
|
} else {
|
|
position.setX(i, position.getX(i) + offset.x);
|
|
position.setY(i, position.getY(i) + offset.y);
|
|
}
|
|
}
|
|
position.needsUpdate = true;
|
|
geometry.computeVertexNormals();
|
|
|
|
const mesh = new THREE.Mesh(geometry, config.material);
|
|
mesh.castShadow = config.cast_shadow !== false;
|
|
mesh.receiveShadow = config.receive_shadow === true;
|
|
mesh.userData.is_environment = true;
|
|
|
|
return {
|
|
'mesh': mesh,
|
|
'curve': curve,
|
|
'radius_top': radius_top,
|
|
'radius_bottom': radius_bottom,
|
|
'height': height
|
|
};
|
|
};
|
|
|
|
|
|
/**
|
|
* Get round/oval branch count from tree height.
|
|
*
|
|
* @param {number} height Total tree height.
|
|
*
|
|
* @return {number}
|
|
*/
|
|
beestat.component.scene.prototype.get_round_tree_branch_count_ = function(height) {
|
|
return Math.max(1, Math.round(beestat.component.scene.round_tree_branches_per_height * Math.max(0, height || 0)));
|
|
};
|
|
|
|
|
|
/**
|
|
* Get normalized branch length factor f(x) for round/oval trees.
|
|
*
|
|
* `x` is normalized distance from trunk base to top in [0, 1]:
|
|
* - 0 = trunk base (ground side)
|
|
* - 1 = trunk top
|
|
*
|
|
* @param {string} tree_type round|oval
|
|
* @param {number} x Normalized distance up trunk [0, 1]
|
|
* @param {number} profile_start_ratio Lower bound for canopy profile in [0, 1).
|
|
* Lower values use more trunk height, higher values use less.
|
|
*
|
|
* @return {number} Branch length factor in [0, 1]
|
|
*/
|
|
beestat.component.scene.prototype.get_branch_length = function(tree_type, x, profile_start_ratio = 0.5) {
|
|
switch (tree_type) {
|
|
case 'oval':
|
|
// Oval equation over x in [start, 1] with softer top taper than round.
|
|
// u = (x - start) / (1 - start), t = 2u - 1, base = sqrt(max(0, 1 - t^2))
|
|
const oval_start = Math.max(0, Math.min(0.95, profile_start_ratio));
|
|
const oval_span = Math.max(0.0001, 1 - oval_start);
|
|
const oval_u = (x - oval_start) / oval_span;
|
|
const oval_t = (oval_u * 2) - 1;
|
|
return x < oval_start || x > 1
|
|
? 0
|
|
: Math.max(0, Math.min(1, Math.pow(Math.sqrt(Math.max(0, 1 - (oval_t * oval_t))), 0.82)));
|
|
case 'round':
|
|
default:
|
|
// Round equation over x in [start, 1] using a true circle cross-section:
|
|
// u = (x - start) / (1 - start), t = 2u - 1, f(x) = sqrt(max(0, 1 - t^2))
|
|
// `start` lowers from 0.5 toward 0 for wide/short trees.
|
|
const start = Math.max(0, Math.min(0.9999, profile_start_ratio));
|
|
const span = Math.max(0.0001, 1 - start);
|
|
const u = (x - start) / span;
|
|
const t = (u * 2) - 1;
|
|
return x < start || x > 1
|
|
? 0
|
|
: Math.max(0, Math.min(1, Math.sqrt(Math.max(0, 1 - (t * t)))));
|
|
}
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
* Create a low-poly round canopy tree scaffold (trunk + first-level branches).
|
|
*
|
|
* @param {number} height Total tree height.
|
|
* @param {number} max_diameter Maximum canopy diameter.
|
|
* @param {boolean} has_foliage Whether foliage should be rendered.
|
|
* @param {string=} canopy_shape Canopy profile passed only to get_branch_length().
|
|
*
|
|
* @return {THREE.Group}
|
|
*/
|
|
beestat.component.scene.prototype.create_round_tree_ = function(height, max_diameter, has_foliage, canopy_shape = 'round') {
|
|
const self = this;
|
|
const tree = new THREE.Group();
|
|
tree.userData.is_environment = true;
|
|
tree.userData.is_tree = true;
|
|
const max_canopy_radius = Math.max(0.5, max_diameter / 2);
|
|
// Use more of trunk height for round profiles when canopy is wide/short.
|
|
// If height == diameter, start reaches 0 (full [0, 1] range).
|
|
const round_canopy_span_ratio = Math.max(0, Math.min(1, max_diameter / Math.max(1, height)));
|
|
const round_canopy_start_ratio = Math.max(0, 1 - round_canopy_span_ratio);
|
|
const canopy_profile_start_ratio = Math.max(0, Math.min(0.9999, round_canopy_start_ratio));
|
|
|
|
const wood_material = new THREE.MeshStandardMaterial({
|
|
'color': 0x6a4d2f,
|
|
'roughness': 0.9,
|
|
'metalness': 0.0,
|
|
'flatShading': true
|
|
});
|
|
|
|
const trunk_height = height;
|
|
const trunk_radius_bottom = Math.max(1.5, trunk_height * 0.03);
|
|
const trunk_stick = this.create_stick_mesh_({
|
|
'height': trunk_height,
|
|
'radius_bottom': trunk_radius_bottom,
|
|
'radial_segments': 7,
|
|
'control_count': 6,
|
|
'max_drift': 8,
|
|
'direction_jitter': 3,
|
|
'taper_start_ratio': 0.35,
|
|
'taper_max_ratio': 0.72,
|
|
'material': wood_material,
|
|
'receive_shadow': true
|
|
});
|
|
const trunk = trunk_stick.mesh;
|
|
trunk.position.z = -(trunk_height / 2) + Math.max(0.7, trunk_radius_bottom * 0.14);
|
|
tree.add(trunk);
|
|
this.add_tree_ground_contact_(tree, trunk_radius_bottom, 0x6a4d2f);
|
|
|
|
// Single branch layer: starts halfway up trunk and thins/shortens toward the top.
|
|
const branch_count = this.get_round_tree_branch_count_(height);
|
|
const branches = new THREE.Group();
|
|
branches.userData.is_environment = true;
|
|
const branch_axis = new THREE.Vector3(0, 0, -1);
|
|
const foliage = new THREE.Group();
|
|
foliage.userData.is_environment = true;
|
|
const canopy_opacity = beestat.component.scene.debug_tree_canopy_opacity;
|
|
const foliage_material = new THREE.MeshStandardMaterial({
|
|
'color': 0x4f9f2f,
|
|
'roughness': 0.82,
|
|
'metalness': 0.0,
|
|
'flatShading': true,
|
|
'transparent': canopy_opacity < 1,
|
|
'opacity': canopy_opacity,
|
|
'depthWrite': canopy_opacity >= 1,
|
|
'side': THREE.DoubleSide
|
|
});
|
|
const create_canopy_from_branch_function_ = function() {
|
|
const center_height = trunk_height * 0.7;
|
|
const center_offset_raw = self.sample_stick_curve_offset_(trunk_stick.curve, center_height);
|
|
const top_offset = self.sample_stick_curve_offset_(trunk_stick.curve, trunk_height);
|
|
// Base canopy center; upper canopy vertices are additionally aligned per-vertex to trunk tip.
|
|
const center_offset = {
|
|
'x': THREE.MathUtils.lerp(center_offset_raw.x, top_offset.x, 0.45),
|
|
'y': THREE.MathUtils.lerp(center_offset_raw.y, top_offset.y, 0.45)
|
|
};
|
|
const center = new THREE.Vector3(center_offset.x, center_offset.y, -center_height);
|
|
const base_radius = Math.max(4, max_canopy_radius * 0.96);
|
|
const geometry = new THREE.IcosahedronGeometry(1, 2);
|
|
const positions = geometry.attributes.position;
|
|
const irregularity = 0.08 + (Math.random() * 0.08);
|
|
const noise_phase_a = Math.random() * Math.PI * 2;
|
|
const noise_phase_b = Math.random() * Math.PI * 2;
|
|
const noise_phase_c = Math.random() * Math.PI * 2;
|
|
const noise_freq_a = 2.7 + (Math.random() * 1.2);
|
|
const noise_freq_b = 2.3 + (Math.random() * 1.2);
|
|
const noise_freq_c = 1.2 + (Math.random() * 0.9);
|
|
const lobe_count = 2 + Math.floor(Math.random() * 4);
|
|
const lobe_amplitude = 0.05 + (Math.random() * 0.08);
|
|
const lobe_phase = Math.random() * Math.PI * 2;
|
|
const squash_x = 0.93 + (Math.random() * 0.14);
|
|
const squash_y = 0.93 + (Math.random() * 0.14);
|
|
const z_wobble = trunk_height * (0.007 + (Math.random() * 0.007));
|
|
const tip_cap_strength = trunk_radius_bottom * (0.85 + (Math.random() * 0.45));
|
|
const tip_round_power = 1.6 + (Math.random() * 1.1);
|
|
const tip_bump_strength = 0.16 + (Math.random() * 0.24);
|
|
const canopy_drift_theta = Math.random() * Math.PI * 2;
|
|
const canopy_drift_radius = max_canopy_radius * 0.02;
|
|
const canopy_drift_x = Math.cos(canopy_drift_theta) * canopy_drift_radius;
|
|
const canopy_drift_y = Math.sin(canopy_drift_theta) * canopy_drift_radius;
|
|
|
|
for (let i = 0; i < positions.count; i++) {
|
|
const x = positions.getX(i);
|
|
const y = positions.getY(i);
|
|
const z = positions.getZ(i);
|
|
const normalized_height = Math.max(0, Math.min(1, (z + 1) / 2));
|
|
// Keep canopy vertices distributed across the active profile band instead
|
|
// of collapsing many points to zero-radius regions.
|
|
const mapped_ratio = canopy_profile_start_ratio + (normalized_height * (1 - canopy_profile_start_ratio));
|
|
const canopy_ratio = mapped_ratio;
|
|
const canopy_z = -trunk_height * canopy_ratio;
|
|
const base_factor = Math.max(
|
|
0,
|
|
Math.min(1, self.get_branch_length(canopy_shape, canopy_ratio, canopy_profile_start_ratio))
|
|
);
|
|
const canopy_factor = base_factor;
|
|
|
|
const radial_length = Math.sqrt((x * x) + (y * y));
|
|
const hx = radial_length > 0.0001 ? x / radial_length : 1;
|
|
const hy = radial_length > 0.0001 ? y / radial_length : 0;
|
|
const theta = Math.atan2(hy, hx);
|
|
|
|
const noise =
|
|
(Math.sin((hx * noise_freq_a) + (hy * (noise_freq_b - 0.4)) + (canopy_ratio * (noise_freq_a + noise_freq_b)) + noise_phase_a) * 0.5) +
|
|
(Math.cos((hx * (noise_freq_b + 0.3)) - (hy * noise_freq_a) - (canopy_ratio * (noise_freq_b + 1.6)) + noise_phase_b) * 0.35) +
|
|
(Math.sin((canopy_ratio * (noise_freq_c + 6.6)) + (hx * noise_freq_c) + noise_phase_c) * 0.15);
|
|
const lobe = 1 + (Math.sin((theta * lobe_count) + (canopy_ratio * 4.4) + lobe_phase) * lobe_amplitude);
|
|
const organic_scale = canopy_factor <= 0 ? 1 : (1 + (noise * irregularity));
|
|
const radius = base_radius * canopy_factor * organic_scale * lobe;
|
|
// Ensure the canopy retains a small cap around the tip so trunk never pokes through.
|
|
const top_cover_t = Math.max(0, Math.min(1, (canopy_ratio - 0.84) / 0.16));
|
|
const min_radius_for_tip_cover = trunk_radius_bottom * 0.55 * top_cover_t;
|
|
const covered_radius = Math.max(radius, min_radius_for_tip_cover);
|
|
const radius_x = covered_radius * squash_x;
|
|
const radius_y = covered_radius * squash_y;
|
|
const canopy_z_offset = Math.sin((theta * (lobe_count + 1)) + lobe_phase) * z_wobble * canopy_factor;
|
|
const top_alignment_t = Math.max(0, Math.min(1, (canopy_ratio - 0.72) / 0.28));
|
|
const center_x = THREE.MathUtils.lerp(
|
|
center.x + canopy_drift_x,
|
|
top_offset.x,
|
|
top_alignment_t
|
|
);
|
|
const center_y = THREE.MathUtils.lerp(
|
|
center.y + canopy_drift_y,
|
|
top_offset.y,
|
|
top_alignment_t
|
|
);
|
|
const top_center_weight = Math.pow(Math.max(0, 1 - radial_length), tip_round_power);
|
|
const tip_bump = 0.5 + (0.5 * Math.sin((theta * (lobe_count + 2)) + lobe_phase + noise_phase_c));
|
|
const tip_cap_lift = tip_cap_strength * top_cover_t * top_center_weight * (1 + (tip_bump * tip_bump_strength));
|
|
const capped_z_offset = canopy_z_offset * (1 - (top_cover_t * 0.85));
|
|
|
|
positions.setXYZ(
|
|
i,
|
|
center_x + (hx * radius_x),
|
|
center_y + (hy * radius_y),
|
|
canopy_z + capped_z_offset - tip_cap_lift
|
|
);
|
|
}
|
|
positions.needsUpdate = true;
|
|
geometry.computeVertexNormals();
|
|
|
|
const canopy_mesh = new THREE.Mesh(geometry, foliage_material.clone());
|
|
canopy_mesh.userData.is_tree_foliage = true;
|
|
canopy_mesh.userData.base_tree_foliage_color = canopy_mesh.material.color.getHex();
|
|
return {
|
|
'mesh': canopy_mesh
|
|
};
|
|
};
|
|
const branch_height_samples = [];
|
|
const recursive_depth_limit = Math.max(
|
|
0,
|
|
Math.round(Number(this.get_scene_setting_('tree_branch_depth') || 0))
|
|
);
|
|
const children_per_branch = 1;
|
|
if (has_foliage === true && this.tree_foliage_meshes_ === undefined) {
|
|
this.tree_foliage_meshes_ = [];
|
|
}
|
|
if (has_foliage === true && this.tree_branch_groups_ === undefined) {
|
|
this.tree_branch_groups_ = [];
|
|
}
|
|
|
|
const initial_branch_direction = new THREE.Vector3(1, 0, -0.2).normalize();
|
|
const branch_rotation_axis = new THREE.Vector3(0, 0, 1);
|
|
const min_sub_branch_fork_angle_radians = THREE.MathUtils.degToRad(15);
|
|
const max_sub_branch_fork_angle_radians = THREE.MathUtils.degToRad(30);
|
|
const get_next_branch_direction = function(previous_direction) {
|
|
const direction = previous_direction.clone().multiplyScalar(-1);
|
|
const angle_offset = (Math.PI / 18) + (Math.random() * ((Math.PI / 4) - (Math.PI / 18)));
|
|
direction.applyQuaternion(
|
|
new THREE.Quaternion().setFromAxisAngle(
|
|
branch_rotation_axis,
|
|
(Math.random() < 0.5 ? -1 : 1) * angle_offset
|
|
)
|
|
);
|
|
// Keep branches more strongly biased upward in this scene's coordinate system (-Z is up).
|
|
direction.z = -Math.max(0.34, Math.abs(direction.z));
|
|
return direction.normalize();
|
|
};
|
|
const get_forked_child_direction = function(parent_direction, child_index, child_count) {
|
|
const parent = parent_direction.clone().normalize();
|
|
const world_up = new THREE.Vector3(0, 0, 1);
|
|
let fork_axis = new THREE.Vector3().crossVectors(parent, world_up);
|
|
if (fork_axis.lengthSq() < 1e-6) {
|
|
fork_axis = new THREE.Vector3().crossVectors(parent, new THREE.Vector3(1, 0, 0));
|
|
}
|
|
fork_axis.normalize();
|
|
|
|
const side = child_count <= 1 ? 1 : (child_index % 2 === 0 ? -1 : 1);
|
|
const fork_angle =
|
|
min_sub_branch_fork_angle_radians +
|
|
(Math.random() * (max_sub_branch_fork_angle_radians - min_sub_branch_fork_angle_radians));
|
|
const forked = parent.clone().applyQuaternion(
|
|
new THREE.Quaternion().setFromAxisAngle(fork_axis, side * fork_angle)
|
|
);
|
|
// Add a small roll around the parent axis so forks don't look planar.
|
|
const roll_angle = (Math.random() - 0.5) * THREE.MathUtils.degToRad(8);
|
|
forked.applyQuaternion(
|
|
new THREE.Quaternion().setFromAxisAngle(parent, roll_angle)
|
|
);
|
|
return forked.normalize();
|
|
};
|
|
for (let i = 0; i < branch_count; i++) {
|
|
const stratified = branch_count <= 1 ? 0.5 : (i / (branch_count - 1));
|
|
const jittered = stratified + ((Math.random() - 0.5) * 0.25 / branch_count);
|
|
branch_height_samples.push(Math.max(0, Math.min(1, jittered)));
|
|
}
|
|
for (let i = branch_height_samples.length - 1; i > 0; i--) {
|
|
const swap_index = Math.floor(Math.random() * (i + 1));
|
|
const temp = branch_height_samples[i];
|
|
branch_height_samples[i] = branch_height_samples[swap_index];
|
|
branch_height_samples[swap_index] = temp;
|
|
}
|
|
|
|
const get_stick_point_world = function(branch_info, ratio) {
|
|
const clamped_ratio = Math.max(0, Math.min(1, ratio));
|
|
const along_height = branch_info.length * clamped_ratio;
|
|
const local_offset = self.sample_stick_curve_offset_(branch_info.stick.curve, along_height);
|
|
const local_point = new THREE.Vector3(
|
|
local_offset.x,
|
|
local_offset.y,
|
|
(branch_info.length / 2) - along_height
|
|
);
|
|
return branch_info.mesh.localToWorld(local_point);
|
|
};
|
|
|
|
const create_branch = function(base, direction, length, radius_bottom) {
|
|
const horizontal_direction_length = Math.sqrt(
|
|
(direction.x * direction.x) + (direction.y * direction.y)
|
|
);
|
|
if (horizontal_direction_length > 0) {
|
|
const base_horizontal_radius = Math.sqrt((base.x * base.x) + (base.y * base.y));
|
|
const max_length_from_diameter =
|
|
(max_canopy_radius - base_horizontal_radius) / horizontal_direction_length;
|
|
if (Number.isFinite(max_length_from_diameter) === true) {
|
|
length = Math.max(0, Math.min(length, max_length_from_diameter));
|
|
}
|
|
}
|
|
length = Math.max(0, length);
|
|
if (length < 1) {
|
|
return null;
|
|
}
|
|
|
|
const branch_stick = self.create_stick_mesh_({
|
|
'height': length,
|
|
'radius_bottom': radius_bottom,
|
|
'radial_segments': 7,
|
|
'control_count': 6,
|
|
'max_drift': length * 0.24,
|
|
'direction_jitter': length * 0.12,
|
|
'straight_start_ratio': 0.2,
|
|
'taper_start_ratio': 0.2,
|
|
'taper_max_ratio': 1,
|
|
'material': wood_material,
|
|
'receive_shadow': false
|
|
});
|
|
const branch = branch_stick.mesh;
|
|
branch.position.copy(base).addScaledVector(direction, (length / 2) - (radius_bottom * 0.45));
|
|
branch.quaternion.setFromUnitVectors(branch_axis, direction);
|
|
branches.add(branch);
|
|
branch.updateMatrixWorld(true);
|
|
|
|
return {
|
|
'mesh': branch,
|
|
'stick': branch_stick,
|
|
'length': length,
|
|
'radius_bottom': radius_bottom,
|
|
'direction': direction.clone()
|
|
};
|
|
};
|
|
|
|
const add_sub_branches = function(parent_branch, depth) {
|
|
if (depth >= recursive_depth_limit) {
|
|
return;
|
|
}
|
|
|
|
for (let j = 0; j < children_per_branch; j++) {
|
|
const attach_ratio = children_per_branch <= 1
|
|
? 0.6
|
|
: 0.35 + (j / (children_per_branch - 1)) * 0.3;
|
|
const attach_point = get_stick_point_world(parent_branch, attach_ratio);
|
|
const child_length = parent_branch.length * 0.62;
|
|
const child_radius_bottom = Math.max(0.15, parent_branch.radius_bottom * 0.62);
|
|
const child_direction = get_forked_child_direction(
|
|
parent_branch.direction,
|
|
j,
|
|
children_per_branch
|
|
);
|
|
const child_branch = create_branch(
|
|
attach_point,
|
|
child_direction,
|
|
child_length,
|
|
child_radius_bottom
|
|
);
|
|
if (child_branch === null) {
|
|
continue;
|
|
}
|
|
add_sub_branches(child_branch, depth + 1);
|
|
}
|
|
};
|
|
|
|
let previous_primary_direction = initial_branch_direction;
|
|
for (let i = 0; i < branch_count; i++) {
|
|
const base_height_ratio = branch_height_samples[i];
|
|
const base_height = trunk_height * base_height_ratio;
|
|
const base_offset = this.sample_stick_curve_offset_(trunk_stick.curve, base_height);
|
|
const branch_length_factor = this.get_branch_length(
|
|
canopy_shape,
|
|
base_height_ratio,
|
|
canopy_profile_start_ratio,
|
|
);
|
|
const branch_length = max_canopy_radius * branch_length_factor;
|
|
if (branch_length <= 0) {
|
|
continue;
|
|
}
|
|
const branch_radius_bottom = Math.max(0.35, trunk_radius_bottom * (0.42 - (base_height_ratio * 0.26)));
|
|
const base = new THREE.Vector3(
|
|
base_offset.x,
|
|
base_offset.y,
|
|
trunk.position.z + (trunk_height / 2) - base_height
|
|
);
|
|
const primary_direction = get_next_branch_direction(previous_primary_direction);
|
|
|
|
const primary_branch = create_branch(
|
|
base,
|
|
primary_direction,
|
|
branch_length,
|
|
branch_radius_bottom
|
|
);
|
|
if (primary_branch === null) {
|
|
continue;
|
|
}
|
|
previous_primary_direction = primary_direction;
|
|
add_sub_branches(primary_branch, 0);
|
|
}
|
|
|
|
if (has_foliage === true) {
|
|
const canopy_seed = this.get_seed_from_parts_([
|
|
this.active_tree_seed_ === undefined ? this.get_scene_setting_('random_seed') : this.active_tree_seed_,
|
|
'canopy'
|
|
]);
|
|
const canopy_result = this.with_random_seed_(canopy_seed, function() {
|
|
return create_canopy_from_branch_function_();
|
|
});
|
|
const canopy_mesh = canopy_result.mesh;
|
|
canopy_mesh.castShadow = true;
|
|
canopy_mesh.receiveShadow = false;
|
|
canopy_mesh.userData.is_environment = true;
|
|
foliage.add(canopy_mesh);
|
|
this.tree_foliage_meshes_.push(canopy_mesh);
|
|
}
|
|
|
|
if (has_foliage === true) {
|
|
this.tree_branch_groups_.push(branches);
|
|
}
|
|
branches.visible =
|
|
this.debug_.hide_tree_branches !== true;
|
|
tree.add(branches);
|
|
if (has_foliage === true) {
|
|
tree.add(foliage);
|
|
}
|
|
|
|
return tree;
|
|
};
|
|
|
|
|
|
/**
|
|
* Create a low-poly oval canopy tree.
|
|
*
|
|
* @param {number} height Total tree height.
|
|
* @param {number} max_diameter Maximum canopy diameter.
|
|
* @param {boolean} has_foliage Whether foliage should be rendered.
|
|
*
|
|
* @return {THREE.Group}
|
|
*/
|
|
beestat.component.scene.prototype.create_oval_tree_ = function(height, max_diameter, has_foliage) {
|
|
return this.create_round_tree_(height, max_diameter, has_foliage, 'oval');
|
|
};
|
|
|