extends RefCounted ## Hangar particle look for Resolute and Kestrel. See PARTICLE_FX.md. ## Drive, RCS, and abort flames are crossed additive ribbons. They are not GPUParticles. const RIBBON := preload("res://ribbon.gdshader") static func exhaust_local(socket: Node3D) -> Vector3: var dir := socket.position if dir.length() < 0.001: dir = Vector3.UP return (socket.quaternion.inverse() * dir).normalized() static func basis_y(dir: Vector3) -> Basis: var y := dir.normalized() if y.length() < 0.001: y = Vector3.UP var up := Vector3.FORWARD if absf(y.dot(Vector3.UP)) > 0.9 else Vector3.UP var x := up.cross(y).normalized() var z := x.cross(y).normalized() return Basis(x, y, z) static func cone(dir: Vector3, deg: float) -> Vector3: var axis := dir.normalized() var up := Vector3.RIGHT if absf(axis.dot(Vector3.UP)) > 0.9 else Vector3.UP var tangent := axis.cross(up).normalized() var bitangent := axis.cross(tangent).normalized() var a := randf() * TAU var r := randf() * deg_to_rad(deg) return (axis + tangent * (cos(a) * r) + bitangent * (sin(a) * r)).normalized() static func _ribbon_mat(root: Color, mid: Color, tip: Color) -> ShaderMaterial: var mat := ShaderMaterial.new() mat.shader = RIBBON mat.set_shader_parameter("root_color", root) mat.set_shader_parameter("mid_color", mid) mat.set_shader_parameter("tip_color", tip) mat.set_shader_parameter("opacity", 0.0) return mat static func _sheet(length: float, width: float) -> ArrayMesh: var half := width * 0.5 var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLES) st.set_uv(Vector2(0, 0)) st.add_vertex(Vector3(-half, 0, 0)) st.set_uv(Vector2(1, 0)) st.add_vertex(Vector3(half, 0, 0)) st.set_uv(Vector2(1, 1)) st.add_vertex(Vector3(half, length, 0)) st.set_uv(Vector2(0, 1)) st.add_vertex(Vector3(-half, length, 0)) st.add_index(0) st.add_index(1) st.add_index(2) st.add_index(0) st.add_index(2) st.add_index(3) return st.commit() static func _taper(length: float, w0: float, w1: float) -> ArrayMesh: var rows := 11 var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLES) for i in rows: var t := float(i) / float(rows - 1) var y := t * length var w := (w0 + (w1 - w0) * pow(t, 0.7)) * 0.5 var v := t st.set_uv(Vector2(0, v)) st.add_vertex(Vector3(-w, y, 0)) st.set_uv(Vector2(1, v)) st.add_vertex(Vector3(w, y, 0)) for i in rows - 1: var a := i * 2 st.add_index(a) st.add_index(a + 1) st.add_index(a + 2) st.add_index(a + 1) st.add_index(a + 3) st.add_index(a + 2) return st.commit() static func _crossed(socket: Node3D, mesh: ArrayMesh, mat: ShaderMaterial, planes: int, name: String, length: float, opacity: float, seed: float) -> Node3D: var g := Node3D.new() g.name = name g.set_meta("mat", mat) g.set_meta("len", length) g.set_meta("opacity", opacity) g.set_meta("seed", seed) g.set_meta("burn", 0.0) for i in planes: var m := MeshInstance3D.new() m.mesh = mesh m.material_override = mat m.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF m.extra_cull_margin = 8.0 m.rotation.y = (float(i) / float(planes)) * PI g.add_child(m) g.basis = basis_y(exhaust_local(socket)) g.visible = false socket.add_child(g) return g static func spear(socket: Node3D, length: float, width: float, root: Color, mid: Color, tip: Color, opacity: float, planes: int, seed: float, node_name: String = "Spear") -> Node3D: if socket.get_node_or_null(node_name): return socket.get_node(node_name) as Node3D return _crossed(socket, _sheet(length, width), _ribbon_mat(root, mid, tip), planes, node_name, length, opacity, seed) static 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: if socket.get_node_or_null(node_name): return socket.get_node(node_name) as Node3D return _crossed(socket, _taper(length, w0, w1), _ribbon_mat(root, mid, tip), planes, node_name, length, opacity, 1.0) static func set_opacity(ribbon: Node3D, opacity: float) -> void: if ribbon == null: return var mat := ribbon.get_meta("mat") as ShaderMaterial var shown := opacity > 0.02 ribbon.visible = shown mat.set_shader_parameter("opacity", opacity if shown else 0.0) static func set_spear(ribbon: Node3D, amount: float, length_scale: float, time: float) -> void: if ribbon == null: return var a := maxf(amount, 0.0) if a < 0.02: set_opacity(ribbon, 0.0) return var seed := float(ribbon.get_meta("seed")) var flick := 0.82 + 0.18 * sin(time * 37.0 + seed * 2.1) var base := float(ribbon.get_meta("opacity")) var s := maxf(length_scale, 0.001) ribbon.scale = Vector3(1, s, 1) set_opacity(ribbon, base * minf(a, 1.0) * flick) static func spot(socket: Node3D, along: float, color: Color, angle_rad: float, range_m: float, decay: float) -> SpotLight3D: var existing := socket.get_node_or_null("ExhaustLight") as SpotLight3D if existing: return existing var aim := basis_y(exhaust_local(socket)) var light := SpotLight3D.new() light.name = "ExhaustLight" light.light_color = color light.light_energy = 0.0 light.spot_range = range_m light.spot_angle = rad_to_deg(angle_rad) light.spot_attenuation = decay light.shadow_enabled = false light.position = aim.y * along var z := -aim.y var up := aim.x var x := up.cross(z).normalized() var y := z.cross(x).normalized() light.basis = Basis(x, y, z) socket.add_child(light) return light static func soft_disc() -> Texture2D: var img := Image.create(64, 64, false, Image.FORMAT_RGBA8) for y in 64: for x in 64: var d := Vector2(x - 31.5, y - 31.5).length() / 32.0 var t := clampf(d, 0.0, 1.0) var s := t * t * (3.0 - 2.0 * t) var a := (1.0 - s) * (1.0 - s) img.set_pixel(x, y, Color(1, 1, 1, a)) return ImageTexture.create_from_image(img) static func _disc_mat() -> StandardMaterial3D: var draw := StandardMaterial3D.new() draw.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA draw.blend_mode = BaseMaterial3D.BLEND_MODE_ADD draw.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED draw.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES draw.vertex_color_use_as_albedo = true draw.albedo_texture = soft_disc() draw.albedo_color = Color.WHITE draw.cull_mode = BaseMaterial3D.CULL_DISABLED draw.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED return draw static func _ramp(offsets: PackedFloat32Array, colors: PackedColorArray) -> GradientTexture1D: var g := Gradient.new() g.offsets = offsets g.colors = colors var tex := GradientTexture1D.new() tex.gradient = g return tex static func sparks(socket: Node3D, node_name: String, amount: int, life: float, vmin: float, vmax: float, spread_deg: float, size: float, reach: float) -> GPUParticles3D: var existing := socket.get_node_or_null(node_name) as GPUParticles3D if existing: return existing var p := GPUParticles3D.new() p.name = node_name p.amount = amount p.lifetime = life p.explosiveness = 0.0 p.local_coords = true p.visibility_aabb = AABB(Vector3(-reach, 0, -reach), Vector3(reach * 2.0, reach * 3.0, reach * 2.0)) var proc := ParticleProcessMaterial.new() proc.direction = Vector3(0, 1, 0) proc.spread = spread_deg proc.initial_velocity_min = vmin proc.initial_velocity_max = vmax proc.gravity = Vector3.ZERO proc.scale_min = 0.85 proc.scale_max = 1.15 proc.color = Color(1, 1, 1, 0.55) p.process_material = proc p.material_override = _disc_mat() var quad := QuadMesh.new() quad.size = Vector2(size, size) p.draw_pass_1 = quad p.emitting = false p.basis = basis_y(exhaust_local(socket)) socket.add_child(p) return p static func abort_flash(socket: Node3D) -> MeshInstance3D: var existing := socket.get_node_or_null("AbortFlash") as MeshInstance3D if existing: return existing var aim := basis_y(exhaust_local(socket)) var mat := StandardMaterial3D.new() mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED mat.albedo_texture = soft_disc() mat.albedo_color = Color(1.0, 0.945, 0.867, 0.0) mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED var m := MeshInstance3D.new() m.name = "AbortFlash" var quad := QuadMesh.new() quad.size = Vector2(1, 1) m.mesh = quad m.material_override = mat m.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF m.position = aim.y * 0.02 m.scale = Vector3.ONE * 0.08 m.visible = false socket.add_child(m) return m static func tracer_mesh() -> ArrayMesh: var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLES) var sides := 5 var r0 := 0.016 var r1 := 0.008 for i in sides: var a0 := TAU * float(i) / float(sides) var a1 := TAU * float(i + 1) / float(sides) var p0 := Vector3(cos(a0) * r0, 0.0, sin(a0) * r0) var p1 := Vector3(cos(a1) * r0, 0.0, sin(a1) * r0) var q0 := Vector3(cos(a0) * r1, 1.0, sin(a0) * r1) var q1 := Vector3(cos(a1) * r1, 1.0, sin(a1) * r1) st.add_vertex(p0) st.add_vertex(q0) st.add_vertex(p1) st.add_vertex(p1) st.add_vertex(q0) st.add_vertex(q1) return st.commit() static func tracer_mat() -> StandardMaterial3D: var draw := StandardMaterial3D.new() draw.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA draw.blend_mode = BaseMaterial3D.BLEND_MODE_ADD draw.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED draw.albedo_color = Color(1.0, 0.902, 0.690, 0.95) draw.cull_mode = BaseMaterial3D.CULL_DISABLED draw.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED return draw static func chaff(socket: Node3D) -> GPUParticles3D: var existing := socket.get_node_or_null("ChaffCloud") as GPUParticles3D if existing: return existing var p := GPUParticles3D.new() p.name = "ChaffCloud" p.amount = 28 p.lifetime = 1.55 p.explosiveness = 0.0 p.local_coords = true p.visibility_aabb = AABB(Vector3(-6, 0, -6), Vector3(12, 16, 12)) var proc := ParticleProcessMaterial.new() proc.direction = Vector3(0, 1, 0) proc.spread = 22.0 proc.initial_velocity_min = 3.0 proc.initial_velocity_max = 8.0 proc.gravity = Vector3.ZERO proc.damping_min = 0.0 proc.damping_max = 0.0 proc.scale_min = 0.07 proc.scale_max = 0.16 proc.color = Color(0.90, 0.93, 0.97, 0.92) proc.color_ramp = _ramp( PackedFloat32Array([0.0, 0.55, 1.0]), PackedColorArray([ Color(0.90, 0.93, 0.97, 0.92), Color(0.90, 0.93, 0.97, 0.7), Color(0.90, 0.93, 0.97, 0.0), ]) ) p.process_material = proc p.material_override = _disc_mat() var quad := QuadMesh.new() quad.size = Vector2(1, 1) p.draw_pass_1 = quad p.emitting = false p.basis = basis_y(exhaust_local(socket)) socket.add_child(p) return p static func smooth(current: float, target: float, delta: float, seconds: float) -> float: var k := 1.0 - exp(-delta / maxf(0.05, seconds)) return current + (target - current) * k