1
0
mirror of https://github.com/beestat/app.git synced 2026-02-26 05:00:21 -05:00
This commit is contained in:
Jon Ziebell 2026-02-21 22:36:00 -05:00
parent 3d31831408
commit 161f5bb27c
5 changed files with 304 additions and 35 deletions

View File

@ -163,10 +163,12 @@ beestat.component.card.three_d.prototype.decorate_contents_ = function(parent) {
const scene_settings_container = document.createElement('div');
Object.assign(scene_settings_container.style, {
'position': 'absolute',
'top': `${beestat.style.size.gutter + 72}px`,
'top': `${beestat.style.size.gutter + 52}px`,
'right': `${beestat.style.size.gutter}px`,
'min-width': '220px',
'max-width': '250px',
'height': '375px',
'overflow-y': 'auto',
'z-index': 2
});
parent.appendChild(scene_settings_container);
@ -581,26 +583,30 @@ beestat.component.card.three_d.prototype.get_weather_settings_from_mode_ = funct
return {
'cloud_density': 0.5,
'rain_density': 0,
'snow_density': 0
'snow_density': 0,
'wind_speed': 2
};
case 'raining':
return {
'cloud_density': 1,
'rain_density': 1,
'snow_density': 0
'snow_density': 0,
'wind_speed': 2
};
case 'snowing':
return {
'cloud_density': 1,
'rain_density': 0,
'snow_density': 1
'snow_density': 1,
'wind_speed': 1
};
case 'sunny':
default:
return {
'cloud_density': 0,
'cloud_density': 0.03,
'rain_density': 0,
'snow_density': 0
'snow_density': 0,
'wind_speed': 1
};
}
};
@ -803,21 +809,41 @@ beestat.component.card.three_d.prototype.decorate_scene_settings_panel_ = functi
});
panel.appendChild(separator);
};
const add_section_title = (title) => {
const heading = document.createElement('div');
Object.assign(heading.style, {
'font-size': '11px',
'letter-spacing': '0.06em',
'text-transform': 'uppercase',
'color': 'rgba(255,255,255,0.75)',
'margin-top': '2px'
});
heading.innerText = title;
panel.appendChild(heading);
};
// Weather
add_section_title('Weather');
add_number_setting(get_title_case_label('cloud_density'), 'cloud_density', 0, 2, 0.1);
add_number_setting(get_title_case_label('rain_density'), 'rain_density', 0, 2, 0.1);
add_number_setting(get_title_case_label('snow_density'), 'snow_density', 0, 2, 0.1);
add_number_setting(get_title_case_label('wind_speed'), 'wind_speed', 0, 2, 0.1);
add_separator();
add_section_title('Wind');
add_number_setting(get_title_case_label('wind_speed'), 'wind_speed', 0, 5, 0.1);
add_number_setting(get_title_case_label('wind_direction'), 'wind_direction', 0, 360, 1);
add_separator();
// Tree
add_section_title('Tree');
add_boolean_setting(get_title_case_label('tree_enabled'), 'tree_enabled');
add_boolean_setting(get_title_case_label('tree_wobble'), 'tree_wobble');
add_separator();
// Light / Sky
add_section_title('Light / Sky');
add_number_setting(get_title_case_label('star_density'), 'star_density', 0, 2, 0.1);
add_boolean_setting(get_title_case_label('light_user_enabled'), 'light_user_enabled');
this.update_fps_visibility_();

View File

@ -74,7 +74,7 @@ beestat.component.scene.environment_padding = 400;
*
* @type {number}
*/
beestat.component.scene.weather_rain_max_count = 2200;
beestat.component.scene.weather_rain_max_count = 1100;
/**
* Maximum snow particle count at full snow intensity.
@ -278,6 +278,8 @@ beestat.component.scene.star_drift_visual_factor = 0.12;
* rain_density: number,
* snow_density: number,
* wind_speed: number,
* wind_direction: number,
* tree_wobble: boolean,
* tree_enabled: boolean,
* star_density: number,
* light_user_enabled: boolean,
@ -289,6 +291,8 @@ beestat.component.scene.default_settings = {
'rain_density': 1,
'snow_density': 1,
'wind_speed': 1,
'wind_direction': 0,
'tree_wobble': true,
'tree_enabled': true,
'star_density': 1,
'light_user_enabled': true,
@ -495,6 +499,7 @@ beestat.component.scene.prototype.reset_runtime_scene_references_for_rerender_ =
this.light_sources_ = [];
this.tree_foliage_meshes_ = [];
this.tree_branch_groups_ = [];
this.tree_wind_meshes_ = [];
delete this.floor_plan_group_;
delete this.environment_group_;
@ -699,6 +704,7 @@ beestat.component.scene.prototype.decorate_ = function(parent) {
self.controls_.update();
self.update_raycaster_();
self.update_celestial_light_intensities_();
self.update_tree_wind_();
self.update_weather_();
self.renderer_.render(self.scene_, self.camera_);
};

View File

@ -138,25 +138,31 @@ beestat.component.scene.prototype.create_snow_particle_texture_ = function() {
/**
* Create a streak-like particle texture for rain.
* Create a soft circular particle texture for rain.
*
* @return {THREE.Texture}
*/
beestat.component.scene.prototype.create_rain_particle_texture_ = function() {
const width = 24;
const height = 64;
const width = 56;
const height = 56;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
const gradient = context.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0.0, 'rgba(170, 200, 255, 0.0)');
gradient.addColorStop(0.25, 'rgba(185, 210, 255, 0.85)');
gradient.addColorStop(1.0, 'rgba(170, 200, 255, 0.0)');
const gradient = context.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width * 0.5
);
gradient.addColorStop(0.0, 'rgba(195, 218, 255, 0.95)');
gradient.addColorStop(0.55, 'rgba(175, 205, 255, 0.55)');
gradient.addColorStop(1.0, 'rgba(165, 198, 255, 0.0)');
context.fillStyle = gradient;
context.fillRect(width / 2 - 2, 0, 4, height);
context.fillRect(0, 0, width, height);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;

View File

@ -2,6 +2,154 @@
* 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(5, 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 * 0.5;
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.
@ -156,6 +304,10 @@ beestat.component.scene.prototype.create_conical_tree_ = function(height, max_di
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) {
@ -247,6 +399,10 @@ beestat.component.scene.prototype.create_conical_tree_ = function(height, max_di
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;
@ -502,6 +658,10 @@ beestat.component.scene.prototype.create_round_tree_ = function(height, max_diam
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.
@ -767,6 +927,10 @@ beestat.component.scene.prototype.create_round_tree_ = function(height, max_diam
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,
@ -860,6 +1024,10 @@ beestat.component.scene.prototype.create_round_tree_ = function(height, max_diam
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) {

View File

@ -24,30 +24,34 @@ beestat.component.scene.prototype.set_weather = function(weather) {
weather_settings = {
'cloud_density': 1,
'rain_density': 0,
'snow_density': 1
'snow_density': 1,
'wind_speed': 1
};
break;
case 'rain':
weather_settings = {
'cloud_density': 1,
'rain_density': 1,
'snow_density': 0
'snow_density': 0,
'wind_speed': 2
};
break;
case 'cloudy':
weather_settings = {
'cloud_density': 0.5,
'rain_density': 0,
'snow_density': 0
'snow_density': 0,
'wind_speed': 2
};
break;
case 'sunny':
case 'none':
default:
weather_settings = {
'cloud_density': 0,
'cloud_density': 0.03,
'rain_density': 0,
'snow_density': 0
'snow_density': 0,
'wind_speed': 1
};
break;
}
@ -329,7 +333,11 @@ beestat.component.scene.prototype.create_precipitation_system_ = function(bounds
'drift_x': drift_x,
'drift_y': drift_y,
'max_count': max_count,
'target_opacity': config.opacity
'target_opacity': config.opacity,
'static_opacity': config.static_opacity === true,
'max_wind_angle': config.max_wind_angle || 0,
'max_wind_speed_scale': config.max_wind_speed_scale || 2,
'wind_motion_multiplier': config.wind_motion_multiplier || 1
};
};
@ -340,8 +348,16 @@ beestat.component.scene.prototype.create_precipitation_system_ = function(bounds
* @param {object} precipitation
* @param {number} target_count
* @param {number} delta_seconds
* @param {number} wind_speed
* @param {number} wind_direction
*/
beestat.component.scene.prototype.update_precipitation_system_ = function(precipitation, target_count, delta_seconds) {
beestat.component.scene.prototype.update_precipitation_system_ = function(
precipitation,
target_count,
delta_seconds,
wind_speed,
wind_direction
) {
if (
precipitation === undefined ||
precipitation.points === undefined ||
@ -357,7 +373,9 @@ beestat.component.scene.prototype.update_precipitation_system_ = function(precip
);
precipitation.points.geometry.setDrawRange(0, clamped_count);
if (precipitation.max_count > 0) {
if (precipitation.static_opacity === true) {
precipitation.points.material.opacity = precipitation.target_opacity;
} else if (precipitation.max_count > 0) {
precipitation.points.material.opacity =
precipitation.target_opacity * (clamped_count / precipitation.max_count);
} else {
@ -373,10 +391,32 @@ beestat.component.scene.prototype.update_precipitation_system_ = function(precip
const span_y = bounds.max_y - bounds.min_y;
const span_z = bounds.max_z - bounds.min_z;
const positions = precipitation.points.geometry.attributes.position.array;
const clamped_wind_speed = Math.max(0, Math.min(5, Number(wind_speed || 0)));
const clamped_wind_direction = Math.max(0, Math.min(360, Number(wind_direction || 0)));
const wind_direction_radians = THREE.MathUtils.degToRad(clamped_wind_direction);
const wind_x = Math.cos(wind_direction_radians);
const wind_y = Math.sin(wind_direction_radians);
const max_wind_angle = Number(precipitation.max_wind_angle || 0);
const wind_angle = (clamped_wind_speed / 5) * max_wind_angle;
const wind_angle_radians = THREE.MathUtils.degToRad(wind_angle);
const vertical_scale = Math.cos(wind_angle_radians);
const horizontal_scale = Math.sin(wind_angle_radians);
const max_wind_speed_scale = Math.max(
1,
Number(precipitation.max_wind_speed_scale || 2)
);
const wind_speed_scale = 1 + ((clamped_wind_speed / 5) * (max_wind_speed_scale - 1));
const wind_motion_multiplier = Math.max(0, Number(precipitation.wind_motion_multiplier || 1));
const direction_velocity_x = horizontal_scale * wind_x;
const direction_velocity_y = horizontal_scale * wind_y;
const direction_velocity_z = vertical_scale;
for (let i = 0; i < clamped_count; i++) {
const offset = i * 3;
positions[offset + 2] += precipitation.speeds[i] * delta_seconds;
const speed = precipitation.speeds[i] * delta_seconds * wind_speed_scale * wind_motion_multiplier;
positions[offset + 2] += speed * direction_velocity_z;
positions[offset] += speed * direction_velocity_x;
positions[offset + 1] += speed * direction_velocity_y;
positions[offset] += precipitation.drift_x[i] * delta_seconds;
positions[offset + 1] += precipitation.drift_y[i] * delta_seconds;
@ -407,11 +447,14 @@ beestat.component.scene.prototype.update_precipitation_system_ = function(precip
*/
beestat.component.scene.prototype.add_weather_ = function(center_x, center_y, plan_width, plan_height) {
const padding = beestat.component.scene.environment_padding + 120;
const weather_span_multiplier = 1.25;
const weather_width = (plan_width + (padding * 2)) * weather_span_multiplier;
const weather_height = (plan_height + (padding * 2)) * weather_span_multiplier;
const bounds = {
'min_x': center_x - ((plan_width + padding * 2) / 2),
'max_x': center_x + ((plan_width + padding * 2) / 2),
'min_y': center_y - ((plan_height + padding * 2) / 2),
'max_y': center_y + ((plan_height + padding * 2) / 2),
'min_x': center_x - (weather_width / 2),
'max_x': center_x + (weather_width / 2),
'min_y': center_y - (weather_height / 2),
'max_y': center_y + (weather_height / 2),
'min_z': -780,
'max_z': 140
};
@ -508,11 +551,14 @@ beestat.component.scene.prototype.add_weather_ = function(center_x, center_y, pl
{
'size': 11,
'color': 0xa8c7ff,
'opacity': 0.7,
'opacity': 0.5,
'static_opacity': true,
'speed_min': 280,
'speed_max': 430,
'drift': 28,
'texture': this.rain_particle_texture_
'texture': this.rain_particle_texture_,
'max_wind_angle': 45,
'max_wind_speed_scale': 2
}
);
this.weather_group_.add(this.rain_particles_.points);
@ -530,7 +576,10 @@ beestat.component.scene.prototype.add_weather_ = function(center_x, center_y, pl
'speed_min': 18,
'speed_max': 44,
'drift': 12,
'texture': this.snow_particle_texture_
'texture': this.snow_particle_texture_,
'max_wind_angle': 75,
'max_wind_speed_scale': 3,
'wind_motion_multiplier': 2.5
}
);
this.weather_group_.add(this.snow_particles_.points);
@ -562,6 +611,8 @@ beestat.component.scene.prototype.update_weather_ = function() {
if (delta_seconds <= 0) {
return;
}
const wind_speed = Math.max(0, Math.min(5, 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)));
if (this.weather_profile_target_ === undefined) {
this.update_weather_targets_();
@ -653,8 +704,20 @@ beestat.component.scene.prototype.update_weather_ = function() {
}
}
this.update_precipitation_system_(this.rain_particles_, this.current_rain_count_, delta_seconds);
this.update_precipitation_system_(this.snow_particles_, this.current_snow_count_, delta_seconds);
this.update_precipitation_system_(
this.rain_particles_,
this.current_rain_count_,
delta_seconds,
wind_speed,
wind_direction
);
this.update_precipitation_system_(
this.snow_particles_,
this.current_snow_count_,
delta_seconds,
wind_speed,
wind_direction
);
this.update_snow_surface_colors_(this.get_snow_cover_blend_());
if (