diff --git a/shaders/Ripple.gdshader b/shaders/Ripple.gdshader new file mode 100644 index 00000000000..f025ae69863 --- /dev/null +++ b/shaders/Ripple.gdshader @@ -0,0 +1,32 @@ +shader_type spatial; + +uniform sampler2D bubble_noise; +uniform sampler2D bubble_gradient; +uniform float smoothness : hint_range(0.0, 1.0, 0.05) = 0.5; +uniform float refraction_strength : hint_range(0.0, 1.0, 0.05) = 0.2; +varying vec3 world_pos; + +void vertex() { + world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + float height = texture(bubble_noise, VERTEX.xz * smoothness + vec2(TIME * 0.1)).r; + VERTEX += NORMAL * height * 0.1; +} + +float fresnel_effect(vec3 normal, vec3 view) { + float fresnel = dot(normalize(view), normalize(normal)); + fresnel = clamp(abs(fresnel), 0.0, 1.0); + return pow(1.0 - fresnel, 2.0); +} + +void fragment() { + float fresnel = fresnel_effect(NORMAL, VIEW); + vec2 distorted_uv = world_pos.xz + vec2(TIME * 0.2); + distorted_uv += NORMAL.xz * refraction_strength; + vec3 gradient_color = texture(bubble_gradient, distorted_uv).rgb; + + ALBEDO = mix(vec3(1.0), gradient_color, 0.5); + METALLIC = 0.9; + ROUGHNESS = 0.01; + SPECULAR = 0.8; + ALPHA = fresnel * 0.5; +} \ No newline at end of file diff --git a/src/extension/CMakeLists.txt b/src/extension/CMakeLists.txt index 838df4b6dc6..3a8aa45b128 100644 --- a/src/extension/CMakeLists.txt +++ b/src/extension/CMakeLists.txt @@ -13,6 +13,7 @@ add_library(thrive_extension SHARED core/ThriveConfig.cpp core/ThriveConfig.hpp interop/ExtensionInterop.cpp interop/ExtensionInterop.h nodes/DebugDrawer.cpp nodes/DebugDrawer.hpp + nodes/Ripple.cpp nodes/Ripple.hpp core/GodotJoltConversions.hpp ) diff --git a/src/extension/core/RegisterThriveTypes.cpp b/src/extension/core/RegisterThriveTypes.cpp index a792443dab7..9df2acbb124 100644 --- a/src/extension/core/RegisterThriveTypes.cpp +++ b/src/extension/core/RegisterThriveTypes.cpp @@ -3,6 +3,8 @@ #include "nodes/DebugDrawer.hpp" +#include "nodes/Ripple.hpp" + #include "ThriveConfig.hpp" // ------------------------------------ // @@ -17,6 +19,7 @@ void InitializeThriveModule(godot::ModuleInitializationLevel level) GDREGISTER_CLASS(Thrive::ThriveConfig); GDREGISTER_CLASS(Thrive::DebugDrawer); + GDREGISTER_CLASS(Thrive::Ripple); } void UnInitializeThriveModule(godot::ModuleInitializationLevel level) diff --git a/src/extension/nodes/Ripple.cpp b/src/extension/nodes/Ripple.cpp new file mode 100644 index 00000000000..2b7c79ea5c6 --- /dev/null +++ b/src/extension/nodes/Ripple.cpp @@ -0,0 +1,196 @@ +#include "Ripple.hpp" + +namespace Thrive { + +// Binds methods to be accessible from Godot +void Ripple::_bind_methods() { + using namespace godot; + + // Method binder + ClassDB::bind_method(D_METHOD("set_material", "material"), &Ripple::SetMaterial); + ClassDB::bind_method(D_METHOD("get_material"), &Ripple::GetMaterial); + + // Add the material property to make it visible and editable in Godot editor + ADD_PROPERTY(PropertyInfo(Variant::OBJECT, "material", + PROPERTY_HINT_RESOURCE_TYPE, "Material", + PROPERTY_USAGE_DEFAULT), + "set_material", "get_material"); +} + +// Constructor: Initializes the Ripple effect and enables processing +Ripple::Ripple() { + // Enable _process to be called every frame + set_process(true); +} + +// Destructor: Cleans up resources when the Ripple effect is destroyed +Ripple::~Ripple() { + isExiting = true; + if (meshInstance) { + meshInstance->queue_free(); + } +} + +// Called when the node exits the scene tree +void Ripple::_exit_tree() { + // clean up references when leaving the scene + isExiting = true; + if (sharedParticles) { + sharedParticles = nullptr; // don't own this just clear reference + } +} + +// Called when the node enters the scene tree +void Ripple::_ready() { + // create and set up the immediate mesh for dynamic geometry + mesh.instantiate(); + meshInstance = memnew(godot::MeshInstance3D); + add_child(meshInstance); + meshInstance->set_mesh(mesh); + + // handle material setup + if (material.is_valid()) { + // use existing material if one was set + meshInstance->set_material_override(material); + } else { + // create default transparent material if none provided + godot::Ref mat; + mat.instantiate(); + mat->set_transparency(godot::BaseMaterial3D::TRANSPARENCY_ALPHA); + mat->set_shading_mode(godot::BaseMaterial3D::SHADING_MODE_UNSHADED); + mat->set_albedo(godot::Color(1, 0, 0, 0.5)); + SetMaterial(mat); + } + + // get reference to the shared particle system in the scene + sharedParticles = get_node("SharedParticles"); + + // store initial position for movement tracking + lastPosition = get_global_position(); + initialized = true; +} + +// Called every frame to update the ripple effect +void Ripple::_process(double delta) { + // skip processing if not properly initialized or being destroyed + if (!initialized || !meshInstance || isExiting) + return; + + // update ripple points, mesh geometry and particle emission + ManageRipplePoints(static_cast(delta)); + UpdateMeshGeometry(); + UpdateParticleEmission(); +} + +// Manages the array of points that form the ripple trail +void Ripple::ManageRipplePoints(float delta) { + // get current position in world space + godot::Vector3 currentPosition = get_global_position(); + + // only create new points when movement threshold is exceeded + if ((currentPosition - lastPosition).length() > MIN_POINT_DISTANCE) { + // shift existing points if at capacity + if (numPoints >= MAX_POINTS) { + // move all points one position back discarding the oldest + for (int i = 0; i < MAX_POINTS - 1; i++) { + points[i] = points[i + 1]; + } + numPoints = MAX_POINTS - 1; + } + + // add new point at current position + points[numPoints].position = currentPosition; + points[numPoints].age = 0; + numPoints++; + + // update last position for next frame's comparison + lastPosition = currentPosition; + } + + // update ages and remove expired points + int alivePoints = 0; + for (int i = 0; i < numPoints; i++) { + // increment age of point + points[i].age += delta; + + // keep point if still within lifetime + if (points[i].age < lifetime) { + // compact array by moving valid points to front if needed + if (i != alivePoints) { + points[alivePoints] = points[i]; + } + alivePoints++; + } + } + // update count of valid points + numPoints = alivePoints; +} + +// Updates the mesh geometry based on current ripple points +void Ripple::UpdateMeshGeometry() { + // clear previous geometry + mesh->clear_surfaces(); + + // need at least 2 points to create geometry + if (numPoints < 2) + return; + + // begin creating triangle strip for the ripple trail + mesh->surface_begin(godot::Mesh::PRIMITIVE_TRIANGLE_STRIP); + + for (int i = 0; i < numPoints; i++) { + // calculate fade based on point age + float alpha = 1.0f - (points[i].age / lifetime); + godot::Color color(1, 0, 0, alpha); + + // calculate direction vector between points + godot::Vector3 direction; + if (i < numPoints - 1) { + // use direction to next point for all except last point + direction = (points[i + 1].position - points[i].position).normalized(); + } else { + // use direction from previous point for last point + direction = (points[i].position - points[i - 1].position).normalized(); + } + + // cross product with up vector creates perpendicular vector + godot::Vector3 side = direction.cross(godot::Vector3(0, 1, 0)).normalized() * rippleWidth; + + // add vertices for both sides of the trail + mesh->surface_set_color(color); + mesh->surface_add_vertex(to_local(points[i].position + side)); + mesh->surface_set_color(color); + mesh->surface_add_vertex(to_local(points[i].position - side)); + } + + // Finish the mesh surface + mesh->surface_end(); +} + +// Updates the shared particle system's emission position +void Ripple::UpdateParticleEmission() { + // only update particles if we have a valid particle system and have moved enough + if (sharedParticles && (lastPosition - get_global_position()).length() > MIN_POINT_DISTANCE) { + // enable emission and update particle system position + sharedParticles->set_emitting(true); + sharedParticles->set_global_position(get_global_position()); + } +} + +// Sets the material used for rendering the ripple effect +void Ripple::SetMaterial(const godot::Ref& p_material) { + // store the material reference + material = p_material; + + // apply material immediately if mesh instance exists + if (meshInstance) { + meshInstance->set_material_override(material); + } +} + +// Returns the current material used by the ripple effect +godot::Ref Ripple::GetMaterial() const { + return material; +} + +} // namespace Thrive \ No newline at end of file diff --git a/src/extension/nodes/Ripple.hpp b/src/extension/nodes/Ripple.hpp new file mode 100644 index 00000000000..b829a825755 --- /dev/null +++ b/src/extension/nodes/Ripple.hpp @@ -0,0 +1,111 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Thrive { + +/** + * The Ripple class creates a visual trails that follow moving microbes, + * gradually fading away like the name suggest, ripples in water. + */ +class Ripple : public godot::Node3D { + GDCLASS(Ripple, godot::Node3D) + +private: + /** + * Holds the data for a single point in our ripple trail + */ + struct RipplePoint { + godot::Vector3 position; ///< where does this point sit in the world? + float age; ///< how long this point has existed or used for fading? + }; + + /// Keep track of this many points + static constexpr size_t MAX_POINTS = 20; + + /// All our ripple points live here + RipplePoint points[MAX_POINTS]; + + /// How many points are currently active + int numPoints = 0; + + /// How wide our ripple effect should be + float rippleWidth = 0.12f; + + /// How long each ripple point sticks around before fading away + float lifetime = 0.3f; + + /// Don't create new points unless we've moved at least this far + static constexpr float MIN_POINT_DISTANCE = 0.01f; + + /// The 3D object that shows our ripple effect + godot::MeshInstance3D* meshInstance = nullptr; + + /// The mesh we update dynamically as ripples form + godot::Ref mesh; + + /// The material that controls how our ripples look + godot::Ref material; + + /// Remember where we were last frame to track movement + godot::Vector3 lastPosition; + + /// Connection to the particle system we share with other effects + godot::CPUParticles3D* sharedParticles = nullptr; + + /// Keep track of our setup state + bool initialized = false; + bool isExiting = false; + +protected: + static void _bind_methods(); + +public: + Ripple(); + ~Ripple(); + + void _ready() override; + void _process(double delta) override; + void _exit_tree() override; + + /** + * Changes the material used for our ripple effect + * @param p_material The new material to use + */ + void SetMaterial(const godot::Ref& p_material); + + /** + * Gets the material we're currently using for the ripple effect + * @return The current material + */ + godot::Ref GetMaterial() const; + +private: + /** + * Updates our mesh to match the current ripple points. + * Creates a smooth trail using triangle strips. + */ + void UpdateMeshGeometry(); + + /** + * Keeps the particle system in sync with our movement. + * Makes sure particles appear in the right place. + */ + void UpdateParticleEmission(); + + /** + * Handles the lifecycle of our ripple points - adds new ones + * as we move and removes old ones as they fade out + * @param delta Time since last frame + */ + void ManageRipplePoints(float delta); +}; + +} // namespace Thrive \ No newline at end of file diff --git a/src/microbe_stage/Membrane.tscn b/src/microbe_stage/Membrane.tscn index 9aa7bcdef98..4b70f1621b1 100644 --- a/src/microbe_stage/Membrane.tscn +++ b/src/microbe_stage/Membrane.tscn @@ -1,17 +1,18 @@ -[gd_scene load_steps=12 format=3 uid="uid://jgcbwcrqbblv"] +[gd_scene load_steps=20 format=3 uid="uid://jgcbwcrqbblv"] [ext_resource type="Script" path="res://src/microbe_stage/Membrane.cs" id="1"] [ext_resource type="Shader" path="res://shaders/Membrane.gdshader" id="2"] [ext_resource type="Texture2D" uid="uid://c3fla17itmoba" path="res://assets/textures/FresnelGradient.png" id="3"] [ext_resource type="Texture2D" uid="uid://lei41d7q7tgk" path="res://assets/textures/FresnelGradientDamaged.png" id="4"] [ext_resource type="Texture2D" uid="uid://baxuoyeo83r2u" path="res://assets/textures/dissolve_noise.tres" id="5"] -[ext_resource type="Shader" path="res://shaders/EngulfEffect.gdshader" id="6_vljlr"] -[ext_resource type="Shader" path="res://shaders/MucocystEffect.gdshader" id="7_grw0d"] +[ext_resource type="Shader" path="res://shaders/EngulfEffect.gdshader" id="6"] +[ext_resource type="Shader" path="res://shaders/MucocystEffect.gdshader" id="7"] +[ext_resource type="Shader" path="res://shaders/Ripple.gdshader" id="8"] -[sub_resource type="BoxMesh" id="1"] +[sub_resource type="BoxMesh" id="BoxMesh_1"] size = Vector3(2, 0.539, 2) -[sub_resource type="ShaderMaterial" id="ShaderMaterial_55k4t"] +[sub_resource type="ShaderMaterial" id="ShaderMaterial_1"] resource_local_to_scene = true render_priority = 18 shader = ExtResource("2") @@ -24,10 +25,10 @@ shader_parameter/albedoTexture = ExtResource("3") shader_parameter/damagedTexture = ExtResource("4") shader_parameter/dissolveTexture = ExtResource("5") -[sub_resource type="ShaderMaterial" id="ShaderMaterial_p8cmc"] +[sub_resource type="ShaderMaterial" id="ShaderMaterial_2"] resource_local_to_scene = true render_priority = 0 -shader = ExtResource("6_vljlr") +shader = ExtResource("6") shader_parameter/wigglyNess = 1.0 shader_parameter/movementWigglyNess = 1.0 shader_parameter/waviness = 40.0 @@ -35,28 +36,90 @@ shader_parameter/waveSpeed = 10.0 shader_parameter/fade = 0.2 shader_parameter/tint = Color(0, 0.55, 0.8, 1) -[sub_resource type="ShaderMaterial" id="ShaderMaterial_lboqi"] +[sub_resource type="ShaderMaterial" id="ShaderMaterial_3"] resource_local_to_scene = true render_priority = 0 -shader = ExtResource("7_grw0d") +shader = ExtResource("7") shader_parameter/wigglyNess = 1.0 shader_parameter/movementWigglyNess = 1.0 shader_parameter/fade = 0.25 shader_parameter/tint = Color(0.4, 0.8, 0.6, 1) +[sub_resource type="Gradient" id="Gradient_1"] +offsets = PackedFloat32Array(0, 0.3, 0.7, 1) +colors = PackedColorArray(1, 1, 1, 1, 0.8, 0.9, 1, 1, 0.9, 0.95, 1, 1, 1, 1, 1, 1) + +[sub_resource type="GradientTexture2D" id="GradientTexture2D_1"] +gradient = SubResource("Gradient_1") +fill = 1 +fill_from = Vector2(0.5, 0.5) +fill_to = Vector2(1, 0.5) + +[sub_resource type="FastNoiseLite" id="FastNoiseLite_1"] +frequency = 0.005 +fractal_octaves = 2 + +[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_1"] +seamless = true +seamless_blend_skirt = 0.4 +noise = SubResource("FastNoiseLite_1") + +[sub_resource type="ShaderMaterial" id="ShaderMaterial_bubble"] +render_priority = 0 +shader = ExtResource("8") +shader_parameter/smoothness = 0.5 +shader_parameter/refraction_strength = 0.2 +shader_parameter/bubble_noise = SubResource("NoiseTexture2D_1") +shader_parameter/bubble_gradient = SubResource("GradientTexture2D_1") + +[sub_resource type="SphereMesh" id="SphereMesh_1"] +material = SubResource("ShaderMaterial_bubble") +radius = 0.9 +height = 1.8 +radial_segments = 32 +rings = 24 + +[sub_resource type="Curve" id="Curve_1"] +_data = [Vector2(0, 0.3), 0.0, 2.0, 0, 0, Vector2(1, 1), 2.0, 0.0, 0, 0] +point_count = 2 + [node name="Membrane" type="MeshInstance3D" node_paths=PackedStringArray("engulfAnimationMeshInstance", "mucocystAnimationMeshInstance")] -process_priority = 2 -cast_shadow = 0 -mesh = SubResource("1") +mesh = SubResource("BoxMesh_1") +surface_material_override/0 = SubResource("ShaderMaterial_1") script = ExtResource("1") -MembraneShaderMaterial = SubResource("ShaderMaterial_55k4t") -EngulfShaderMaterial = SubResource("ShaderMaterial_p8cmc") -MucocystShaderMaterial = SubResource("ShaderMaterial_lboqi") +MembraneShaderMaterial = SubResource("ShaderMaterial_1") +EngulfShaderMaterial = SubResource("ShaderMaterial_2") +MucocystShaderMaterial = SubResource("ShaderMaterial_3") engulfAnimationMeshInstance = NodePath("EngulfMesh") mucocystAnimationMeshInstance = NodePath("MucocystMesh") [node name="EngulfMesh" type="MeshInstance3D" parent="."] visible = false +layers = 2 +material_override = SubResource("ShaderMaterial_2") +cast_shadow = 0 +mesh = SubResource("BoxMesh_1") [node name="MucocystMesh" type="MeshInstance3D" parent="."] visible = false +layers = 2 +material_override = SubResource("ShaderMaterial_3") +cast_shadow = 0 +mesh = SubResource("BoxMesh_1") + +[node name="Ripple" type="Node3D" parent="."] + +[node name="SharedParticles" type="CPUParticles3D" parent="Ripple"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.1, 0) +amount = 4 +lifetime = 0.3 +fixed_fps = 60 +draw_order = 2 +mesh = SubResource("SphereMesh_1") +particle_flag_align_y = true +direction = Vector3(0, 0, 0) +spread = 0.0 +initial_velocity_min = 0.001 +initial_velocity_max = 0.001 +scale_amount_min = 0.3 +scale_amount_curve = SubResource("Curve_1")