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()