Download scripts/modules/simulation.gd from geter62/monkeystudio: direct link, hf CLI and curl.
- Browser
- Download file 10.3 kB
-
https://huggingface.co/geter62/monkeystudio/resolve/main/scripts/modules/simulation.gd
- Command line
-
hf download hf://geter62/monkeystudio/scripts/modules/simulation.gd
-
curl -L -o simulation.gd https://huggingface.co/geter62/monkeystudio/resolve/main/scripts/modules/simulation.gd
10.3 kB
| extends Node | |
| ## | |
| ## SimulationModule - lightweight mass-spring solver for cloth & hair | |
| ## (Features 61-80), tuned for mobile CPUs. | |
| ## | |
| ## Approach: a fixed grid of particles connected by springs (structural, | |
| ## shear, bend). Integration uses fixed-dt sub-stepping with Verlet and | |
| ## constraint relaxation (Jakobsen) which is stable at low iteration counts - | |
| ## far cheaper than a full PBD/XPBD solver while looking convincing. | |
| ## | |
| ## Memory: particle + spring arrays are PackedArrays; nothing is allocated per | |
| ## frame. The whole sim is torn down by FeatureRegistry.deactivate() when the | |
| ## user leaves the module, so its RAM returns to the pool. | |
| ## | |
| ## sim_id -> { settings, particles, springs, mesh, instance } | |
| var _sims: Dictionary = {} | |
| var _host: Node = null | |
| var _accumulator: float = 0.0 | |
| const FIXED_DT := 1.0 / 30.0 | |
| const MAX_SUBSTEPS := 4 | |
| func _activate(host: Node) -> void: | |
| _host = host | |
| set_process(true) | |
| StudioBus.sim_toggled.connect(_on_sim_toggled) | |
| StudioBus.sim_settings_changed.connect(_on_settings_changed) | |
| print("[Simulation] activated") | |
| func _deactivate() -> void: | |
| if StudioBus.sim_toggled.is_connected(_on_sim_toggled): | |
| StudioBus.sim_toggled.disconnect(_on_sim_toggled) | |
| if StudioBus.sim_settings_changed.is_connected(_on_settings_changed): | |
| StudioBus.sim_settings_changed.disconnect(_on_settings_changed) | |
| _sims.clear() | |
| _host = null | |
| func _on_sim_toggled(sim_id: String, enabled: bool) -> void: | |
| if enabled and not _sims.has(sim_id): | |
| _build_default(sim_id) | |
| if _sims.has(sim_id): | |
| _sims[sim_id]["settings"]["enabled"] = enabled | |
| func _on_settings_changed(sim_id: String, settings: Dictionary) -> void: | |
| if not _sims.has(sim_id): | |
| return | |
| for k in settings: | |
| _sims[sim_id]["settings"][k] = settings[k] | |
| func _build_default(sim_id: String) -> void: | |
| if sim_id == "sim.cloth": | |
| create_cloth("sim.cloth", 12, 12, 1.0) | |
| elif sim_id == "sim.hair": | |
| create_hair("sim.hair", 10, 10) | |
| ## --- Cloth ------------------------------------------------------------------ | |
| ## Create a cloth grid of cols x rows, size meters wide (square), hung from the | |
| ## top edge and simulated against gravity + wind. | |
| func create_cloth(sim_id: String, cols: int, rows: int, size: float) -> void: | |
| cols = maxi(2, cols) | |
| rows = maxi(2, rows) | |
| var particles := PackedVector3Array() | |
| var pinned := PackedByteArray() | |
| var step := size / float(cols - 1) | |
| for r in range(rows): | |
| for c in range(cols): | |
| particles.append(Vector3((c - (cols - 1) * 0.5) * step, -r * step, 0.0)) | |
| # Pin the top row so the cloth hangs. | |
| pinned.append(1 if r == 0 else 0) | |
| var springs := PackedInt32Array() | |
| var rest := PackedFloat32Array() | |
| _add_structural(cols, rows, particles, springs, rest) | |
| _add_shear(cols, rows, particles, springs, rest) | |
| var mesh := MeshPool.acquire_mesh() | |
| var mi := _make_instance("Cloth") | |
| _sims[sim_id] = { | |
| "settings": { | |
| "enabled": true, "gravity": -9.8, "wind": Vector3(0.6, 0.0, 0.2), | |
| "damping": 0.02, "iterations": 6, "stiffness": 1.0, "cols": cols, "rows": rows, | |
| }, | |
| "particles": particles, "prev": particles.duplicate(), "pinned": pinned, | |
| "springs": springs, "rest": rest, "mesh": mesh, "instance": mi, | |
| } | |
| func _add_structural(cols: int, rows: int, pos: PackedVector3Array, | |
| springs: PackedInt32Array, rest: PackedFloat32Array) -> void: | |
| for r in range(rows): | |
| for c in range(cols): | |
| var i := r * cols + c | |
| if c + 1 < cols: | |
| _push_spring(i, i + 1, pos, springs, rest) | |
| if r + 1 < rows: | |
| _push_spring(i, i + cols, pos, springs, rest) | |
| func _add_shear(cols: int, rows: int, pos: PackedVector3Array, | |
| springs: PackedInt32Array, rest: PackedFloat32Array) -> void: | |
| for r in range(rows - 1): | |
| for c in range(cols - 1): | |
| var i := r * cols + c | |
| _push_spring(i, i + cols + 1, pos, springs, rest) | |
| _push_spring(i + 1, i + cols, pos, springs, rest) | |
| func _push_spring(a: int, b: int, pos: PackedVector3Array, | |
| springs: PackedInt32Array, rest: PackedFloat32Array) -> void: | |
| springs.append(a) | |
| springs.append(b) | |
| rest.append(pos[a].distance_to(pos[b])) | |
| ## --- Hair ------------------------------------------------------------------- | |
| ## Create `strands` strands, each `segments` long, rooted along a horizontal line. | |
| func create_hair(sim_id: String, strands: int, segments: int) -> void: | |
| var particles := PackedVector3Array() | |
| var pinned := PackedByteArray() | |
| var springs := PackedInt32Array() | |
| var rest := PackedFloat32Array() | |
| var spread := 1.0 | |
| for s in range(strands): | |
| var x := (float(s) / float(maxi(1, strands - 1)) - 0.5) * spread | |
| var base := particles.size() | |
| for seg in range(segments): | |
| particles.append(Vector3(x, 0.5 - seg * 0.08, 0.0)) | |
| pinned.append(1 if seg == 0 else 0) | |
| for seg in range(segments - 1): | |
| _push_spring(base + seg, base + seg + 1, particles, springs, rest) | |
| var mesh := MeshPool.acquire_mesh() | |
| var mi := _make_instance("Hair") | |
| _sims[sim_id] = { | |
| "settings": { | |
| "enabled": true, "gravity": -9.8, "wind": Vector3(0.2, 0.0, 0.4), | |
| "damping": 0.05, "iterations": 4, "stiffness": 0.8, | |
| "strands": strands, "segments": segments, | |
| }, | |
| "particles": particles, "prev": particles.duplicate(), "pinned": pinned, | |
| "springs": springs, "rest": rest, "mesh": mesh, "instance": mi, | |
| } | |
| func _make_instance(name: String) -> MeshInstance3D: | |
| var mi := MeshInstance3D.new() | |
| mi.name = name | |
| var mat := StandardMaterial3D.new() | |
| mat.albedo_color = Color(0.55, 0.6, 0.75) | |
| mat.cull_mode = BaseMaterial3D.CULL_DISABLED | |
| mi.material_override = mat | |
| if _host != null: | |
| var geom := _host.get_node_or_null("Geometry") | |
| (geom if geom else _host).add_child(mi) | |
| # Collision is only needed for the rays; nothing to add here for now. | |
| return mi | |
| ## --- Solver ---------------------------------------------------------------- | |
| func _process(delta: float) -> void: | |
| var any := false | |
| for id in _sims: | |
| var sim: Dictionary = _sims[id] | |
| if sim["settings"]["enabled"]: | |
| any = true | |
| break | |
| if not any: | |
| return | |
| _accumulator += delta | |
| var steps := 0 | |
| while _accumulator >= FIXED_DT and steps < MAX_SUBSTEPS: | |
| for id in _sims: | |
| var sim: Dictionary = _sims[id] | |
| if sim["settings"]["enabled"]: | |
| _step(sim) | |
| _accumulator -= FIXED_DT | |
| steps += 1 | |
| if steps >= MAX_SUBSTEPS: | |
| _accumulator = 0.0 # drop backlog instead of spiralling | |
| for id in _sims: | |
| _upload(_sims[id]) | |
| ## Verlet integration + Jacobi-style constraint relaxation. | |
| func _step(sim: Dictionary) -> void: | |
| var s: Dictionary = sim["settings"] | |
| var pos: PackedVector3Array = sim["particles"] | |
| var prev: PackedVector3Array = sim["prev"] | |
| var pinned: PackedByteArray = sim["pinned"] | |
| var gravity: float = s["gravity"] | |
| var wind: Vector3 = s["wind"] | |
| var damping: float = s["damping"] | |
| var dt := FIXED_DT | |
| for i in range(pos.size()): | |
| if pinned[i] == 1: | |
| continue | |
| var velocity := (pos[i] - prev[i]) * (1.0 - damping) | |
| prev[i] = pos[i] | |
| var accel := Vector3(0, gravity, 0) + wind | |
| pos[i] = pos[i] + velocity + accel * dt * dt | |
| var springs: PackedInt32Array = sim["springs"] | |
| var rest: PackedFloat32Array = sim["rest"] | |
| var stiffness: float = s["stiffness"] | |
| for _it in range(int(s["iterations"])): | |
| for k in range(rest.size()): | |
| var a := springs[k * 2] | |
| var b := springs[k * 2 + 1] | |
| var delta_v := pos[b] - pos[a] | |
| var d := delta_v.length() | |
| if d < 0.00001: | |
| continue | |
| var diff := (d - rest[k]) / d | |
| var correction := delta_v * 0.5 * diff * stiffness | |
| if pinned[a] == 0: | |
| pos[a] = pos[a] + correction | |
| if pinned[b] == 0: | |
| pos[b] = pos[b] - correction | |
| ## Upload the current particle positions as a triangle strip / line mesh. | |
| func _upload(sim: Dictionary) -> void: | |
| var pos: PackedVector3Array = sim["particles"] | |
| var mesh: ArrayMesh = sim["mesh"] | |
| var cols: int = sim["settings"].get("cols", 0) | |
| var rows: int = sim["settings"].get("rows", 0) | |
| if cols > 1 and rows > 1: | |
| _upload_cloth(pos, cols, rows, mesh) | |
| else: | |
| _upload_hair(pos, sim["settings"].get("strands", 0), sim["settings"].get("segments", 0), mesh) | |
| func _upload_cloth(pos: PackedVector3Array, cols: int, rows: int, mesh: ArrayMesh) -> void: | |
| var indices := PackedInt32Array() | |
| for r in range(rows - 1): | |
| for c in range(cols - 1): | |
| var i := r * cols + c | |
| indices.append(i); indices.append(i + 1); indices.append(i + cols) | |
| indices.append(i + 1); indices.append(i + cols + 1); indices.append(i + cols) | |
| var uvs := PackedVector2Array() | |
| uvs.resize(pos.size()) | |
| for i in range(pos.size()): | |
| uvs[i] = Vector2(float(i % cols) / float(cols), float(i / cols) / float(rows)) | |
| var normals := VertexBufferManager.recompute_normals(pos, indices) | |
| mesh.clear_surfaces() | |
| var arrays: Array = [] | |
| arrays.resize(Mesh.ARRAY_MAX) | |
| arrays[Mesh.ARRAY_VERTEX] = pos | |
| arrays[Mesh.ARRAY_NORMAL] = normals | |
| arrays[Mesh.ARRAY_TEX_UV] = uvs | |
| arrays[Mesh.ARRAY_INDEX] = indices | |
| mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) | |
| func _upload_hair(pos: PackedVector3Array, strands: int, segments: int, mesh: ArrayMesh) -> void: | |
| if strands <= 1 or segments <= 1: | |
| return | |
| var ribbons := PackedVector3Array() | |
| var indices := PackedInt32Array() | |
| var uvs := PackedVector2Array() | |
| var width := 0.02 | |
| for s in range(strands): | |
| var base := s * segments | |
| var line_base := ribbons.size() | |
| for seg in range(segments): | |
| var p := pos[base + seg] | |
| var tangent := Vector3.RIGHT | |
| if seg + 1 < segments: | |
| tangent = (pos[base + seg + 1] - p).normalized() | |
| var side := tangent.cross(Vector3.FORWARD).normalized() * width | |
| ribbons.append(p - side) | |
| ribbons.append(p + side) | |
| uvs.append(Vector2(0, float(seg) / float(segments))) | |
| uvs.append(Vector2(1, float(seg) / float(segments))) | |
| for seg in range(segments - 1): | |
| var a := line_base + seg * 2 | |
| indices.append(a); indices.append(a + 1); indices.append(a + 2) | |
| indices.append(a + 1); indices.append(a + 3); indices.append(a + 2) | |
| var normals := VertexBufferManager.recompute_normals(ribbons, indices) | |
| mesh.clear_surfaces() | |
| var arrays: Array = [] | |
| arrays.resize(Mesh.ARRAY_MAX) | |
| arrays[Mesh.ARRAY_VERTEX] = ribbons | |
| arrays[Mesh.ARRAY_NORMAL] = normals | |
| arrays[Mesh.ARRAY_TEX_UV] = uvs | |
| arrays[Mesh.ARRAY_INDEX] = indices | |
| mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) | |
| func set_wind(sim_id: String, wind: Vector3) -> void: | |
| if _sims.has(sim_id): | |
| _sims[sim_id]["settings"]["wind"] = wind | |
| func active_sim_count() -> int: | |
| return _sims.size() | |