From 161f5bb27c84987293c29dcae844d0a322245c4a Mon Sep 17 00:00:00 2001 From: Jon Ziebell Date: Sat, 21 Feb 2026 22:36:00 -0500 Subject: [PATCH] Weather --- js/component/card/three_d.js | 40 ++++++-- js/component/scene.js | 8 +- js/component/scene/texture.js | 24 +++-- js/component/scene/tree.js | 168 ++++++++++++++++++++++++++++++++++ js/component/scene/weather.js | 99 ++++++++++++++++---- 5 files changed, 304 insertions(+), 35 deletions(-) diff --git a/js/component/card/three_d.js b/js/component/card/three_d.js index 6f8225f..3a68c66 100644 --- a/js/component/card/three_d.js +++ b/js/component/card/three_d.js @@ -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_(); diff --git a/js/component/scene.js b/js/component/scene.js index a45ae65..9d974db 100644 --- a/js/component/scene.js +++ b/js/component/scene.js @@ -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_); }; diff --git a/js/component/scene/texture.js b/js/component/scene/texture.js index 38a72f2..bc84309 100644 --- a/js/component/scene/texture.js +++ b/js/component/scene/texture.js @@ -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; diff --git a/js/component/scene/tree.js b/js/component/scene/tree.js index fc003cb..db56864 100644 --- a/js/component/scene/tree.js +++ b/js/component/scene/tree.js @@ -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) { diff --git a/js/component/scene/weather.js b/js/component/scene/weather.js index 1a1d8b3..f07f253 100644 --- a/js/component/scene/weather.js +++ b/js/component/scene/weather.js @@ -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 (