Particle FX fix

particles on kestrel are working, landing gear and hatch animations are not
This commit is contained in:
scorpion21211-cmd
2026-10-08 00:46:40 -04:00
parent 3bf5338ede
commit fa7bbacb22
4 changed files with 312 additions and 195 deletions
+132 -87
View File
@@ -1,3 +1,4 @@
@tool
extends Node3D
## Kestrel EC-01 plume driver. Godot 4.7.
## Attach to the Kestrel child. Leave escape_capsule.gd on EscapeCapsule.
@@ -7,21 +8,27 @@ extends Node3D
## listen_keys is off so walking the player does not light jets or shove the craft.
## Capsule sets abort_burn, att_pitch/yaw/roll, rcs_direction, hatches, cabin_dim.
@export var listen_keys := false
@export var listen_keys := false # leave off; capsule owns G H J L K Space
@export_range(0.0, 1.0) var gear_deploy := 1.0
@export_range(0.0, 1.0) var hatch_crew_open := 0.0
@export_range(0.0, 1.0) var hatch_ladder_deploy := 0.0
@export_range(0.0, 1.0) var hatch_dock_open := 0.0
@export_range(0.0, 1.0) var instruments_demo := 0.4
@export var cabin_lights := true
@export var cabin_dim := false
@export var cabin_lights := true:
set(value):
cabin_lights = value
if is_inside_tree():
_apply_cabin()
@export var cabin_dim := false:
set(value):
cabin_dim = value
if is_inside_tree():
_apply_cabin()
@export_enum("Off", "Nose", "Tail", "Starboard", "Port", "Dorsal", "Ventral") var rcs_direction := 0
@export var att_pitch := 0.0
@export var att_yaw := 0.0
@export var att_roll := 0.0
@export var abort_burn := false
## Tick this in the inspector (or Remote tab while running) to self-test every jet.
@export var debug_fire := false
const _RIBBON := "shader_type spatial;
render_mode blend_add, unshaded, cull_disabled, depth_draw_never, fog_disabled;
@@ -42,17 +49,14 @@ void fragment() {
}"
var _player: AnimationPlayer
var _clip_players: Dictionary = {}
var _shader: Shader
var _rcs: Array[Node3D] = []
var _aborts: Array[Dictionary] = []
var _abort_hold := 0.0
var _time := 0.0
var _prev := {}
var _clip_cache := {}
var _warm_frames := 12
var _cabin_ready := false
var _applied_dim := false
var _applied_lights := true
var _cabin_lights_n: Array[Light3D] = []
var _cabin_rest: Array[Dictionary] = []
var _cabin_mats: Array[Dictionary] = []
@@ -69,33 +73,33 @@ func _ready() -> void:
_player = get_node_or_null("AnimationPlayer") as AnimationPlayer
if _player == null:
_player = find_child("AnimationPlayer", true, false) as AnimationPlayer
if _player == null and get_parent():
_player = get_parent().find_child("AnimationPlayer", true, false) as AnimationPlayer
_split_clips()
_arm()
if _aborts.is_empty():
_fallback_abort()
_collect_cabin()
_apply_clips()
print("Kestrel driver ready: rcs=", _rcs.size(), " abort=", _aborts.size(),
" cabin_lights=", _cabin_lights_n.size(), " strips=", _cabin_mats.size(),
" anim_player=", _player != null)
if _rcs.is_empty():
push_warning("Kestrel driver: no Vfx_RCS_* nodes found under " + str(get_path()))
if _aborts.is_empty():
push_warning("Kestrel driver: no Vfx_Abort_* nodes found under " + str(get_path()))
if _player == null:
push_warning("Kestrel driver: no AnimationPlayer found (gear/hatches will not move)")
var clips := PackedStringArray()
if _player:
clips = _player.get_animation_list()
print("Kestrel driver ready rcs=", _rcs.size(), " abort=", _aborts.size(), " clips=", clips, " lights=", _cabin_lights_n.size())
func _physics_process(delta: float) -> void:
func drive(thrusting: bool, pitch: float, yaw: float, roll: float) -> void:
abort_burn = thrusting
att_pitch = pitch
att_yaw = yaw
att_roll = roll
func _process(delta: float) -> void:
_time += delta
if listen_keys:
if listen_keys and not Engine.is_editor_hint():
_read_keys()
if debug_fire:
abort_burn = true
rcs_direction = 1 + int(_time) % 6
_apply_clips()
if _warm_frames > 0:
_warm_frames -= 1
# FIX: the capsule sets cabin_dim as a plain variable, so nothing re-applied it.
if cabin_dim != _applied_dim or cabin_lights != _applied_lights:
_apply_cabin()
_apply_cabin()
_burn(_wish(), delta)
@@ -112,6 +116,7 @@ func _read_keys() -> void:
instruments_demo = 0.15 if instruments_demo > 0.5 else 1.0
if _pressed(KEY_K):
cabin_lights = not cabin_lights
_apply_cabin()
abort_burn = Input.is_physical_key_pressed(KEY_SPACE)
@@ -145,41 +150,64 @@ func _wish() -> Vector3:
return (global_transform.basis * local).normalized()
func _apply_clips() -> void:
_seek("gear_deploy", gear_deploy)
_seek("hatch_crew_open", hatch_crew_open)
_seek("hatch_ladder_deploy", hatch_ladder_deploy)
_seek("hatch_dock_open", hatch_dock_open)
_seek("instruments_demo", instruments_demo)
var _anim_root: Node = null
## FIX: only re-seek when the value actually changed. The old version called
## play()+seek()+pause() on five clips every physics frame.
func _clip_name(clip: StringName) -> StringName:
if _player.has_animation(clip):
return clip
for a in _player.get_animation_list():
if String(a).ends_with("/" + String(clip)):
return a
return &""
## Re-seeks only when the value changed, but keeps re-applying for the first few
## frames so the pose sticks after the AnimationPlayer finishes initializing.
func _seek(clip: StringName, t: float) -> void:
func _split_clips() -> void:
_anim_root = self
if _player == null:
print("Kestrel clips: no AnimationPlayer")
return
var clip_id := _clip_name(clip)
if clip_id == &"":
var root := _player.get_node_or_null(_player.root_node)
if root:
_anim_root = root
print("Kestrel clips root=", _anim_root.name, " list=", _player.get_animation_list())
func _apply_clips() -> void:
_pose("gear_deploy", gear_deploy)
_pose("hatch_crew_open", hatch_crew_open)
_pose("hatch_ladder_deploy", hatch_ladder_deploy)
_pose("hatch_dock_open", hatch_dock_open)
_pose("instruments_demo", instruments_demo)
func _pose(clip: StringName, t: float) -> void:
if _player == null or not _player.has_animation(clip):
return
t = clampf(t, 0.0, 1.0)
if _warm_frames <= 0 and _clip_cache.has(clip) and is_equal_approx(float(_clip_cache[clip]), t):
return
_clip_cache[clip] = t
var clip_len := _player.get_animation(clip_id).length
_player.play(clip_id)
_player.seek(t * clip_len, true)
_player.pause()
var anim := _player.get_animation(clip)
var at := clampf(t, 0.0, 1.0) * anim.length
var root: Node = _anim_root if _anim_root else self
for i in anim.get_track_count():
var full := String(anim.track_get_path(i))
var node_path := full.get_slice(":", 0)
var prop := full.get_slice(":", 1)
var node := root.get_node_or_null(NodePath(node_path))
if node == null:
node = find_child(node_path.get_file(), true, false)
if node == null:
continue
var typ := anim.track_get_type(i)
if typ == Animation.TYPE_ROTATION_3D:
var q := anim.rotation_track_interpolate(i, at)
if prop == "" or prop == "quaternion" or prop == "rotation":
(node as Node3D).quaternion = q
else:
node.set(prop, q)
elif typ == Animation.TYPE_POSITION_3D:
var pos := anim.position_track_interpolate(i, at)
if prop == "" or prop == "position":
(node as Node3D).position = pos
else:
node.set(prop, pos)
elif typ == Animation.TYPE_SCALE_3D:
var sc := anim.scale_track_interpolate(i, at)
if prop == "" or prop == "scale":
(node as Node3D).scale = sc
else:
node.set(prop, sc)
elif typ == Animation.TYPE_VALUE and prop != "":
node.set(prop, anim.value_track_interpolate(i, at))
func _collect_cabin() -> void:
@@ -219,24 +247,36 @@ func _collect_cabin() -> void:
func _apply_cabin() -> void:
if not _cabin_ready:
return
_applied_dim = cabin_dim
_applied_lights = cabin_lights
var dim := cabin_dim or not cabin_lights
for i in _cabin_lights_n.size():
var lamp := _cabin_lights_n[i]
var rest: Dictionary = _cabin_rest[i]
lamp.light_color = rest.color
lamp.light_energy = float(rest.energy) * (0.08 if dim else 1.0)
lamp.light_energy = float(rest.energy) * (0.16 if dim else 1.0)
for row in _cabin_mats:
var mat := row.mat as StandardMaterial3D
mat.emission_enabled = true if dim else bool(row.on)
mat.emission = row.color
mat.emission_energy_multiplier = float(row.energy) * (0.05 if dim else 1.0)
mat.emission_energy_multiplier = float(row.energy) * (0.18 if dim else 1.0)
func _scope() -> Array[Node]:
var found: Array[Node] = []
var roots: Array[Node] = [self]
if get_parent():
roots.append(get_parent())
for root in roots:
found.append(root)
for n in root.find_children("*", "Node", true, false):
found.append(n)
return found
func _arm() -> void:
var i := 0
for n in find_children("*", "Node3D", true, false):
for n in _scope():
if not (n is Node3D):
continue
var nm := String(n.name)
if nm.begins_with("Vfx_RCS_"):
_rcs.append(_spear(n, 1.2, 0.09, Color(0.863, 0.937, 1.0, 0.95), Color(0.41, 0.67, 1.0, 0.35), Color(0.25, 0.45, 0.8, 0.0), 0.82, 2, float(i)))
@@ -245,27 +285,34 @@ func _arm() -> void:
_aborts.append(_make_abort(n))
func _fallback_abort() -> void:
var spots: Array[Vector3] = [Vector3(0.55, -0.35, 0.55), Vector3(-0.55, -0.35, 0.55), Vector3(0.55, -0.35, -0.55), Vector3(-0.55, -0.35, -0.55)]
for i in spots.size():
var s := Marker3D.new()
s.name = "Vfx_Abort_Fallback_%d" % i
s.position = spots[i]
add_child(s)
_aborts.append(_make_abort(s))
print("Kestrel driver built fallback abort plumes")
func _burn(wish: Vector3, delta: float) -> void:
var ship := global_transform.basis
var ship_inv := ship.inverse()
# FIX: capsule yaws about +Z by +att_yaw (rotate_object_local(FORWARD, -yaw) == +Z, +yaw),
# so the commanded angular velocity is (pitch, roll, +yaw). The old -att_yaw fired the
# opposite-side jets for yaw.
var cmd := Vector3(att_pitch, att_roll, att_yaw)
var cmd := Vector3(att_pitch, att_roll, -att_yaw)
var turning := cmd.length() > 0.15
var origin := global_position
for ribbon in _rcs:
var want := 0.0
var exhaust: Vector3 = (ship_inv * ribbon.global_transform.basis.y).normalized()
var exhaust: Vector3 = (ship.inverse() * ribbon.global_transform.basis.y).normalized()
if turning:
var local_pos: Vector3 = ship_inv * (ribbon.global_position - origin)
var local_pos: Vector3 = ship.inverse() * (ribbon.global_position - origin)
if local_pos.length() < 0.05:
local_pos = -exhaust
var torque: Vector3 = local_pos.cross(-exhaust)
if torque.dot(cmd) > 0.12:
want = 1.0
if want < 0.5 and wish != Vector3.ZERO:
if exhaust.dot(-(ship_inv * wish)) > 0.7:
if exhaust.dot(-(ship.inverse() * wish)) > 0.7:
want = 1.0
var burn := float(ribbon.get_meta("burn"))
burn = _smooth(burn, want, delta, 0.08 if want > burn else 0.14)
@@ -306,8 +353,6 @@ func _burn(wish: Vector3, delta: float) -> void:
light.light_energy = _abort_hold * 8.0 if on else 0.0
## Exhaust direction in the socket's parent space. The model puts each Vfx_ socket's
## translation along its exhaust direction (RCS = +Y, Abort = -Y), so position is the aim.
func _exhaust(socket: Node3D) -> Vector3:
var dir := socket.position
if dir.length() < 0.001:
@@ -365,13 +410,13 @@ func _taper(length: float, w0: float, w1: float) -> ArrayMesh:
return st.commit()
func _crossed(socket: Node3D, mesh: ArrayMesh, mat: ShaderMaterial, planes: int, node_name: String, length: float, opacity: float, flick_seed: float) -> Node3D:
func _crossed(socket: Node3D, mesh: ArrayMesh, mat: ShaderMaterial, planes: int, node_name: String, length: float, opacity: float, seed: float) -> Node3D:
var g := Node3D.new()
g.name = node_name
g.set_meta("mat", mat)
g.set_meta("len", length)
g.set_meta("opacity", opacity)
g.set_meta("seed", flick_seed)
g.set_meta("seed", seed)
g.set_meta("burn", 0.0)
for i in planes:
var m := MeshInstance3D.new()
@@ -387,8 +432,8 @@ func _crossed(socket: Node3D, mesh: ArrayMesh, mat: ShaderMaterial, planes: int,
return g
func _spear(socket: Node3D, length: float, width: float, root: Color, mid: Color, tip: Color, opacity: float, planes: int, flick_seed: float) -> Node3D:
return _crossed(socket, _sheet(length, width), _ribbon_mat(root, mid, tip), planes, "Spear", length, opacity, flick_seed)
func _spear(socket: Node3D, length: float, width: float, root: Color, mid: Color, tip: Color, opacity: float, planes: int, seed: float) -> Node3D:
return _crossed(socket, _sheet(length, width), _ribbon_mat(root, mid, tip), planes, "Spear", length, opacity, seed)
func _taper_ribbon(socket: Node3D, length: float, w0: float, w1: float, root: Color, mid: Color, tip: Color, opacity: float, planes: int, node_name: String) -> Node3D:
@@ -407,8 +452,8 @@ func _set_spear(ribbon: Node3D, amount: float, time: float) -> void:
if a < 0.02:
_set_opacity(ribbon, 0.0)
return
var flick_seed := float(ribbon.get_meta("seed"))
var flick := 0.82 + 0.18 * sin(time * 37.0 + flick_seed * 2.1)
var seed := float(ribbon.get_meta("seed"))
var flick := 0.82 + 0.18 * sin(time * 37.0 + seed * 2.1)
ribbon.scale = Vector3.ONE
_set_opacity(ribbon, float(ribbon.get_meta("opacity")) * minf(a, 1.0) * flick)
@@ -534,11 +579,11 @@ func _spot(socket: Node3D) -> SpotLight3D:
func _make_abort(socket: Node3D) -> Dictionary:
var core := _taper_ribbon(socket, 2.1, 0.06, 0.20, Color(1.0, 0.97, 0.92, 1.0), Color(1.0, 0.45, 0.12, 0.72), Color(0.40, 0.08, 0.02, 0.0), 0.95, 3, "AbortCore")
var sheath := _taper_ribbon(socket, 5.2, 0.10, 0.78, Color(1.0, 0.55, 0.18, 1.0), Color(1.0, 0.28, 0.06, 0.55), Color(0.35, 0.06, 0.01, 0.0), 0.82, 3, "AbortSheath")
return {
"core": core,
"sheath": sheath,
"sparks": _sparks(socket, "AbortCoreSparks", 10, 0.15, 18.0, 28.0, 1.6, 0.05, 6.0),
"sheath_sparks": _sparks(socket, "AbortSheathSparks", 18, 0.26, 14.0, 22.0, 4.5, 0.09, 8.0),
"flash": _abort_flash(socket),
"light": _spot(socket),
}
var row: Dictionary = {}
row["core"] = core
row["sheath"] = sheath
row["sparks"] = _sparks(socket, "AbortCoreSparks", 10, 0.15, 18.0, 28.0, 1.6, 0.05, 6.0)
row["sheath_sparks"] = _sparks(socket, "AbortSheathSparks", 18, 0.26, 14.0, 22.0, 4.5, 0.09, 8.0)
row["flash"] = _abort_flash(socket)
row["light"] = _spot(socket)
return row