mirror of
https://github.com/beestat/app.git
synced 2026-02-26 13:10:23 -05:00
1090 lines
40 KiB
JavaScript
1090 lines
40 KiB
JavaScript
/**
|
|
* Scene methods split from scene.js.
|
|
*/
|
|
|
|
/**
|
|
* Register a tree mesh for procedural wind bending.
|
|
*
|
|
* @param {THREE.Mesh} mesh
|
|
* @param {{stiffness:number, max_sway_ratio:number}=} options
|
|
*/
|
|
beestat.component.scene.prototype.register_tree_wind_mesh_ = function(mesh, options) {
|
|
if (
|
|
mesh === undefined ||
|
|
mesh.geometry === undefined ||
|
|
mesh.geometry.attributes === undefined ||
|
|
mesh.geometry.attributes.position === undefined
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const position_attribute = mesh.geometry.attributes.position;
|
|
const source_positions = position_attribute.array;
|
|
if (source_positions === undefined || source_positions.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (this.tree_wind_meshes_ === undefined) {
|
|
this.tree_wind_meshes_ = [];
|
|
}
|
|
|
|
const count = position_attribute.count;
|
|
const base_positions = new Float32Array(source_positions.length);
|
|
base_positions.set(source_positions);
|
|
const weights = new Float32Array(count);
|
|
const phase_offsets = new Float32Array(count);
|
|
|
|
const mesh_offset_z = Number(mesh.position !== undefined ? mesh.position.z : 0);
|
|
let min_world_z = Infinity;
|
|
let max_world_z = -Infinity;
|
|
for (let i = 0; i < count; i++) {
|
|
const world_z = base_positions[(i * 3) + 2] + mesh_offset_z;
|
|
min_world_z = Math.min(min_world_z, world_z);
|
|
max_world_z = Math.max(max_world_z, world_z);
|
|
}
|
|
const height = Math.max(0.0001, max_world_z - min_world_z);
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const offset = i * 3;
|
|
const x = base_positions[offset];
|
|
const y = base_positions[offset + 1];
|
|
const world_z = base_positions[offset + 2] + mesh_offset_z;
|
|
const height_ratio = (max_world_z - world_z) / height;
|
|
const clamped_ratio = Math.max(0, Math.min(1, height_ratio));
|
|
|
|
// Keep trunk/branch bases anchored and increase bending toward tips.
|
|
weights[i] = Math.pow(clamped_ratio, 1.75);
|
|
phase_offsets[i] = ((x * 0.02) + (y * 0.015)) * 0.4;
|
|
}
|
|
|
|
const resolved_options = options || {};
|
|
const stiffness = Math.max(0.1, Number(resolved_options.stiffness || 1));
|
|
const max_sway_ratio = Math.max(
|
|
0,
|
|
Number(resolved_options.max_sway_ratio === undefined ? 0.03 : resolved_options.max_sway_ratio)
|
|
);
|
|
|
|
this.tree_wind_meshes_.push({
|
|
'mesh': mesh,
|
|
'base_positions': base_positions,
|
|
'weights': weights,
|
|
'phase_offsets': phase_offsets,
|
|
'height': height,
|
|
'stiffness': stiffness,
|
|
'max_sway_ratio': max_sway_ratio,
|
|
'phase_seed': Math.random() * Math.PI * 2
|
|
});
|
|
};
|
|
|
|
|
|
/**
|
|
* Update tree vertex sway for current wind speed.
|
|
* Wind direction is single-axis to keep motion physically directional.
|
|
*/
|
|
beestat.component.scene.prototype.update_tree_wind_ = function() {
|
|
if (this.tree_wind_meshes_ === undefined || this.tree_wind_meshes_.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const wind_speed = Math.max(0, Math.min(2, Number(this.get_scene_setting_('wind_speed') || 0)));
|
|
const wind_direction = Math.max(0, Math.min(360, Number(this.get_scene_setting_('wind_direction') || 0)));
|
|
const tree_wobble_enabled = this.get_scene_setting_('tree_wobble') !== false;
|
|
// Keep overall tree effect lower than prior tuning while preserving responsiveness.
|
|
const wind_strength = wind_speed * 1.25;
|
|
const time_seconds = window.performance.now() / 1000;
|
|
const wind_radians = THREE.MathUtils.degToRad(wind_direction);
|
|
const wind_direction_x = Math.cos(wind_radians);
|
|
const wind_direction_y = Math.sin(wind_radians);
|
|
const gust = 0.78 + (0.22 * Math.sin(time_seconds * 0.16));
|
|
|
|
for (let i = 0; i < this.tree_wind_meshes_.length; i++) {
|
|
const wind_mesh = this.tree_wind_meshes_[i];
|
|
const mesh = wind_mesh.mesh;
|
|
if (
|
|
mesh === undefined ||
|
|
mesh.geometry === undefined ||
|
|
mesh.geometry.attributes === undefined ||
|
|
mesh.geometry.attributes.position === undefined
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const position_attribute = mesh.geometry.attributes.position;
|
|
const positions = position_attribute.array;
|
|
const base_positions = wind_mesh.base_positions;
|
|
const weights = wind_mesh.weights;
|
|
const phase_offsets = wind_mesh.phase_offsets;
|
|
const count = position_attribute.count;
|
|
|
|
if (tree_wobble_enabled !== true || wind_strength <= 0) {
|
|
for (let vertex_index = 0; vertex_index < count; vertex_index++) {
|
|
const offset = vertex_index * 3;
|
|
positions[offset] = base_positions[offset];
|
|
positions[offset + 1] = base_positions[offset + 1];
|
|
positions[offset + 2] = base_positions[offset + 2];
|
|
}
|
|
position_attribute.needsUpdate = true;
|
|
continue;
|
|
}
|
|
|
|
const max_sway = wind_mesh.height * wind_mesh.max_sway_ratio * (wind_strength / wind_mesh.stiffness);
|
|
const steady_lean = max_sway * 0.28;
|
|
const oscillation_strength = max_sway * (0.58 + (0.24 * gust));
|
|
const frequency = (0.75 + (wind_strength * 0.42)) / Math.max(0.25, wind_mesh.stiffness);
|
|
|
|
for (let vertex_index = 0; vertex_index < count; vertex_index++) {
|
|
const offset = vertex_index * 3;
|
|
const weight = weights[vertex_index];
|
|
const phase = (time_seconds * frequency) + wind_mesh.phase_seed + phase_offsets[vertex_index];
|
|
const oscillation =
|
|
Math.sin(phase) +
|
|
(Math.sin((phase * 2.1) + 0.6) * 0.25);
|
|
const along_wind = (steady_lean + (oscillation * oscillation_strength)) * weight;
|
|
|
|
positions[offset] = base_positions[offset] + (wind_direction_x * along_wind);
|
|
positions[offset + 1] = base_positions[offset + 1] + (wind_direction_y * along_wind);
|
|
positions[offset + 2] = base_positions[offset + 2];
|
|
}
|
|
|
|
position_attribute.needsUpdate = true;
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* 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.register_tree_wind_mesh_(trunk, {
|
|
'stiffness': 2.4,
|
|
'max_sway_ratio': 0.01
|
|
});
|
|
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);
|
|
this.register_tree_wind_mesh_(foliage_mesh, {
|
|
'stiffness': 1.1,
|
|
'max_sway_ratio': 0.045
|
|
});
|
|
|
|
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':
|
|
// Same circular profile as round, but start lower to elongate canopy.
|
|
const oval_start = Math.max(0, Math.min(0.9999, profile_start_ratio - 0.2));
|
|
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.sqrt(Math.max(0, 1 - (oval_t * oval_t)))));
|
|
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.register_tree_wind_mesh_(trunk, {
|
|
'stiffness': 2.2,
|
|
'max_sway_ratio': 0.012
|
|
});
|
|
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;
|
|
// Derive where canopy profile actually begins from the active branch-length
|
|
// function so canopy bottom matches branches for all shapes.
|
|
let canopy_geometry_start_ratio = canopy_profile_start_ratio;
|
|
const canopy_start_samples = 40;
|
|
const canopy_start_threshold = 0.005;
|
|
for (let sample_index = 0; sample_index <= canopy_start_samples; sample_index++) {
|
|
const sample_ratio = (sample_index / canopy_start_samples) * canopy_profile_start_ratio;
|
|
const sample_length = self.get_branch_length(
|
|
canopy_shape,
|
|
sample_ratio,
|
|
canopy_profile_start_ratio
|
|
);
|
|
if (sample_length > canopy_start_threshold) {
|
|
canopy_geometry_start_ratio = sample_ratio;
|
|
break;
|
|
}
|
|
}
|
|
|
|
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));
|
|
// Sample canopy directly in the same profile range as branches.
|
|
const canopy_ratio =
|
|
canopy_geometry_start_ratio +
|
|
(normalized_height * (1 - canopy_geometry_start_ratio));
|
|
const canopy_z = -trunk_height * canopy_ratio;
|
|
// Avoid exact-zero sampling at the profile start to prevent rare base pinches.
|
|
const canopy_sample_ratio = Math.min(1, Math.max(
|
|
canopy_ratio,
|
|
canopy_geometry_start_ratio + 0.012
|
|
));
|
|
const base_factor = Math.max(
|
|
0,
|
|
Math.min(1, self.get_branch_length(canopy_shape, canopy_sample_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;
|
|
// Minimal base guard in the first canopy slice to avoid rare pinches
|
|
// without materially changing taller-tree profiles.
|
|
const base_cover_t = Math.max(0, Math.min(1, normalized_height / 0.12));
|
|
const min_radius_for_base_cover =
|
|
trunk_radius_bottom * 0.18 * (1 - base_cover_t);
|
|
// 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_base_cover,
|
|
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(beestat.component.scene.fixed_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 min_sub_branch_fork_angle_radians = THREE.MathUtils.degToRad(15);
|
|
const max_sub_branch_fork_angle_radians = THREE.MathUtils.degToRad(30);
|
|
const recent_primary_branch_angle_limit = 10;
|
|
const get_angle_distance = function(angle_a, angle_b) {
|
|
return Math.abs(Math.atan2(Math.sin(angle_a - angle_b), Math.cos(angle_a - angle_b)));
|
|
};
|
|
const get_weighted_primary_branch_theta = function(existing_angles) {
|
|
if (existing_angles.length === 0) {
|
|
return Math.random() * Math.PI * 2;
|
|
}
|
|
|
|
const candidate_count = Math.max(18, Math.min(56, branch_count * 4));
|
|
const candidates = [];
|
|
let total_weight = 0;
|
|
for (let i = 0; i < candidate_count; i++) {
|
|
const theta = Math.random() * Math.PI * 2;
|
|
let min_distance = Math.PI;
|
|
for (let j = 0; j < existing_angles.length; j++) {
|
|
min_distance = Math.min(min_distance, get_angle_distance(theta, existing_angles[j]));
|
|
}
|
|
// Any angle is possible, but wider gaps get a higher chance.
|
|
const normalized_distance = min_distance / Math.PI;
|
|
const weight = 0.08 + Math.pow(normalized_distance, 2.4);
|
|
total_weight += weight;
|
|
candidates.push({
|
|
'theta': theta,
|
|
'weight': weight
|
|
});
|
|
}
|
|
|
|
let pick = Math.random() * total_weight;
|
|
for (let i = 0; i < candidates.length; i++) {
|
|
pick -= candidates[i].weight;
|
|
if (pick <= 0) {
|
|
return candidates[i].theta;
|
|
}
|
|
}
|
|
|
|
return candidates[candidates.length - 1].theta;
|
|
};
|
|
const get_primary_branch_direction = function(existing_angles) {
|
|
const theta = get_weighted_primary_branch_theta(existing_angles);
|
|
const upward_bias = -(0.34 + (Math.random() * 0.26));
|
|
return {
|
|
'theta': theta,
|
|
'direction': new THREE.Vector3(Math.cos(theta), Math.sin(theta), upward_bias).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);
|
|
self.register_tree_wind_mesh_(branch, {
|
|
'stiffness': 1.7,
|
|
'max_sway_ratio': 0.02
|
|
});
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
const primary_branch_angles = [];
|
|
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_result = get_primary_branch_direction(primary_branch_angles);
|
|
const primary_direction = primary_direction_result.direction;
|
|
|
|
const primary_branch = create_branch(
|
|
base,
|
|
primary_direction,
|
|
branch_length,
|
|
branch_radius_bottom
|
|
);
|
|
if (primary_branch === null) {
|
|
continue;
|
|
}
|
|
primary_branch_angles.push(primary_direction_result.theta);
|
|
if (primary_branch_angles.length > recent_primary_branch_angle_limit) {
|
|
primary_branch_angles.shift();
|
|
}
|
|
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);
|
|
this.register_tree_wind_mesh_(canopy_mesh, {
|
|
'stiffness': 1.0,
|
|
'max_sway_ratio': 0.04
|
|
});
|
|
}
|
|
|
|
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');
|
|
};
|
|
|