monkeystudio / scripts /modules /simulation.gd
geter62's picture
Upload folder using huggingface_hub
84e8204 verified
Raw History Blame Contribute Delete
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()