File size: 8,177 Bytes
84e8204 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 | extends Node
##
## SculptModule - vertex-based sculpting brushes (Features 21-40).
##
## Works directly on a MeshInstance3D's surface arrays. A stroke is:
## begin(pos) -> apply(pos, delta) x N -> end()
## All applies accumulate into CPU PackedArrays; a single GPU upload happens in
## end() (and optionally on a timer) so we do NOT upload per touch-move event.
##
## Brushes:
## draw - push vertices along the surface normal / view dir
## grab - drag vertices inside the radius with the pointer
## pinch - pull vertices toward the brush center
## smooth - laplacian relax toward neighbour average
## flatten - move vertices toward the local average plane
##
@export var radius: float = 0.5
@export var strength: float = 0.5
@export var invert: bool = false # hold shift / tap toggle to reverse
var _host: Node = null
var _target: MeshInstance3D = null
var _verts: PackedVector3Array = PackedVector3Array()
var _indices: PackedInt32Array = PackedInt32Array()
var _uvs: PackedVector2Array = PackedVector2Array()
var _base_verts: PackedVector3Array = PackedVector3Array()
var _dirty := false
var _stroke_active := false
var _brush_id := "draw"
var _grab_origin := Vector3.ZERO
var _grab_delta := Vector3.ZERO
func _activate(host: Node) -> void:
_host = host
StudioBus.brush_changed.connect(_on_brush_changed)
StudioBus.brush_radius_changed.connect(func(r: float): radius = r)
StudioBus.brush_strength_changed.connect(func(s: float): strength = s)
StudioBus.object_selected.connect(_on_object_selected)
print("[Sculpt] activated")
func _deactivate() -> void:
flush()
if StudioBus.brush_changed.is_connected(_on_brush_changed):
StudioBus.brush_changed.disconnect(_on_brush_changed)
if StudioBus.object_selected.is_connected(_on_object_selected):
StudioBus.object_selected.disconnect(_on_object_selected)
_host = null
_target = null
func _on_brush_changed(id: String) -> void:
_brush_id = id
func _on_object_selected(node: Node3D) -> void:
set_target(node as MeshInstance3D)
## Bind a MeshInstance3D and cache its arrays.
func set_target(mi: MeshInstance3D) -> void:
_target = mi
if mi == null or mi.mesh == null or mi.mesh.get_surface_count() == 0:
return
var arr := mi.mesh.surface_get_arrays(0)
_verts = arr[Mesh.ARRAY_VERTEX]
_uvs = arr[Mesh.ARRAY_TEX_UV] if arr.size() > Mesh.ARRAY_TEX_UV and arr[Mesh.ARRAY_TEX_UV] != null else PackedVector2Array()
_indices = arr[Mesh.ARRAY_INDEX] if arr.size() > Mesh.ARRAY_INDEX and arr[Mesh.ARRAY_INDEX] != null else PackedInt32Array()
_base_verts = _verts.duplicate()
func begin(world_pos: Vector3) -> void:
if _verts.is_empty():
return
_stroke_active = true
_dirty = false
_grab_origin = world_pos
_grab_delta = Vector3.ZERO
StudioBus.stroke_begin.emit(world_pos)
func apply(world_pos: Vector3, move_delta: Vector3 = Vector3.ZERO) -> void:
if not _stroke_active or _verts.is_empty():
return
match _brush_id:
"draw": _brush_draw(world_pos, move_delta)
"grab": _brush_grab(world_pos, move_delta)
"pinch": _brush_pinch(world_pos)
"smooth": _brush_smooth(world_pos)
"flatten": _brush_flatten(world_pos)
_dirty = true
StudioBus.stroke_apply.emit(world_pos, move_delta)
func end() -> void:
if not _stroke_active:
return
_stroke_active = false
flush()
StudioBus.stroke_end.emit()
## Upload the accumulated CPU edits to the GPU in one shot.
func flush() -> void:
if not _dirty or _target == null or _target.mesh == null:
return
VertexBufferManager.commit(_verts, _indices, _uvs, _target.mesh)
_base_verts = _verts.duplicate()
_dirty = false
StudioBus.scene_modified.emit()
## --- Brush implementations --------------------------------------------------
func _falloff(dist: float) -> float:
if dist >= radius:
return 0.0
var t := 1.0 - (dist / radius)
return t * t * (3.0 - 2.0 * t) # smoothstep
func _brush_draw(center: Vector3, move_delta: Vector3) -> void:
var dir := move_delta.normalized() if move_delta.length_squared() > 0.000001 else Vector3.UP
var sign := -1.0 if invert else 1.0
var amp := strength * radius * 0.25 * sign
var r2 := radius * radius
for i in range(_verts.size()):
var d2 := _verts[i].distance_squared_to(center)
if d2 < r2:
var w := _falloff(sqrt(d2))
_verts[i] = _verts[i] + dir * amp * w
func _brush_grab(center: Vector3, move_delta: Vector3) -> void:
_grab_delta += move_delta
var r2 := radius * radius
# Move relative to where the stroke started for a stable, non-drifting feel.
for i in range(_verts.size()):
var src := _base_verts[i]
var d2 := src.distance_squared_to(_grab_origin)
if d2 < r2:
var w := _falloff(sqrt(d2))
_verts[i] = src + _grab_delta * w
func _brush_pinch(center: Vector3) -> void:
var sign := -1.0 if invert else 1.0
var r2 := radius * radius
for i in range(_verts.size()):
var v := _verts[i]
var d2 := v.distance_squared_to(center)
if d2 < r2:
var w := _falloff(sqrt(d2))
var to_center := (center - v)
_verts[i] = v + to_center * strength * 0.15 * w * sign
func _brush_smooth(center: Vector3) -> void:
# Approximate neighbour average using the vertex's triangle neighbours.
var sums := PackedVector3Array()
var counts := PackedInt32Array()
sums.resize(_verts.size())
counts.resize(_verts.size())
var tri := _indices.size() / 3
for t in range(tri):
var a := _indices[t * 3]
var b := _indices[t * 3 + 1]
var c := _indices[t * 3 + 2]
for pair in [[a, b], [b, c], [c, a]]:
var k: int = pair[1]
sums[k] = sums[k] + _verts[pair[0]]
counts[k] = counts[k] + 1
var k2: int = pair[0]
sums[k2] = sums[k2] + _verts[k]
counts[k2] = counts[k2] + 1
var r2 := radius * radius
for i in range(_verts.size()):
if _verts[i].distance_squared_to(center) < r2 and counts[i] > 0:
var avg := sums[i] / float(counts[i])
var w := _falloff(_verts[i].distance_to(center))
_verts[i] = _verts[i].lerp(avg, clampf(strength * w, 0.0, 1.0))
func _brush_flatten(center: Vector3) -> void:
var r2 := radius * radius
# Find the average normal of affected verts -> local plane.
var avg_normal := Vector3.ZERO
var affected: Array[int] = []
for i in range(_verts.size()):
if _verts[i].distance_squared_to(center) < r2:
affected.append(i)
if affected.is_empty():
return
for i in affected:
avg_normal = avg_normal + (_base_verts[i] - center)
avg_normal = avg_normal.normalized() if avg_normal.length_squared() > 0.000001 else Vector3.UP
for i in affected:
var v := _verts[i]
var dist_to_plane := v.dot(avg_normal) - center.dot(avg_normal)
var w := _falloff(v.distance_to(center))
_verts[i] = v - avg_normal * dist_to_plane * clampf(strength * w, 0.0, 1.0)
## Ray from camera through screen point; returns closest approach to mesh AABB.
## A physics collider is preferred; this fallback keeps sculpting usable even
## when the mesh has no collision shape (common right after generation).
func raycast_screen(camera: Camera3D, screen_pos: Vector2) -> Dictionary:
if camera == null or _target == null:
return {"hit": false}
var origin := camera.project_ray_origin(screen_pos)
var dir := camera.project_ray_normal(screen_pos)
var space := _host.get_viewport().world_3d.direct_space_state
var q := PhysicsRayQueryParameters3D.create(origin, origin + dir * 1000.0)
q.collide_with_areas = true
var res := space.intersect_ray(q)
if res.is_empty():
# Fallback: intersect ray with the mesh AABB as an approximation.
var aabb := _target.mesh.get_aabb()
var box_pos := _target.global_transform * aabb.position
var t := _ray_box(origin, dir, _target.global_transform * (aabb.position + aabb.size * 0.5), aabb.size.length() * 0.5)
if t > 0.0:
return {"hit": true, "position": origin + dir * t, "normal": Vector3.UP}
return {"hit": false}
return {"hit": true, "position": res["position"], "normal": res["normal"]}
func _ray_box(origin: Vector3, dir: Vector3, center: Vector3, r: float) -> float:
# Sphere approximation of the mesh bounds for the fallback ray test.
var oc := origin - center
var b := oc.dot(dir)
var c := oc.dot(oc) - r * r
var disc := b * b - c
if disc < 0.0:
return -1.0
var t := -b - sqrt(disc)
return t if t > 0.0 else (-b + sqrt(disc))
|