class_name Fx ## Procedural particle effects -- sparks, smoke, tyre dust. No textures: soft ## billboard quads shaped entirely by process-material ramps built in code, ## same no-assets philosophy as the sound kit. static var _spark_mat: ParticleProcessMaterial static var _smoke_mat: ParticleProcessMaterial static var _dust_mat: ParticleProcessMaterial static var _spark_draw: QuadMesh static var _smoke_draw: QuadMesh static func _ramp(colors: Array[Color]) -> GradientTexture1D: var g := Gradient.new() for i in colors.size(): if i == 0: g.set_color(0, colors[0]) elif i == colors.size() - 1 and colors.size() > 1: g.set_color(g.get_point_count() - 1, colors[i]) else: g.add_point(float(i) / (colors.size() - 1), colors[i]) var t := GradientTexture1D.new() t.gradient = g return t static func _draw_quad(size: float, color: Color, emissive: bool) -> QuadMesh: var q := QuadMesh.new() q.size = Vector2(size, size) var m := StandardMaterial3D.new() m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED m.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES m.vertex_color_use_as_albedo = true m.albedo_color = color # radial soft falloff -- without it every particle is a hard-edged square # and smoke reads as flying pixels rather than puffs var grad := Gradient.new() grad.set_color(0, Color.WHITE) grad.set_color(1, Color(1, 1, 1, 0)) var disc := GradientTexture2D.new() disc.gradient = grad disc.fill = GradientTexture2D.FILL_RADIAL disc.fill_from = Vector2(0.5, 0.5) disc.fill_to = Vector2(0.5, 0.02) disc.width = 64 disc.height = 64 m.albedo_texture = disc if emissive: m.emission_enabled = true m.emission = color m.emission_energy_multiplier = 2.0 m.blend_mode = BaseMaterial3D.BLEND_MODE_ADD q.material = m return q static func _init_shared() -> void: if _spark_mat: return _spark_mat = ParticleProcessMaterial.new() _spark_mat.direction = Vector3(0, 1, 0) _spark_mat.spread = 75.0 _spark_mat.initial_velocity_min = 4.0 _spark_mat.initial_velocity_max = 11.0 _spark_mat.gravity = Vector3(0, -14, 0) _spark_mat.scale_min = 0.5 _spark_mat.scale_max = 1.0 _spark_mat.color_ramp = _ramp([Color(1.0, 0.9, 0.5), Color(1.0, 0.45, 0.05), Color(0.6, 0.1, 0.0, 0.0)]) _spark_draw = _draw_quad(0.09, Color(1, 0.7, 0.2), true) _smoke_mat = ParticleProcessMaterial.new() _smoke_mat.direction = Vector3(0, 1, 0) _smoke_mat.spread = 25.0 _smoke_mat.initial_velocity_min = 0.8 _smoke_mat.initial_velocity_max = 1.8 _smoke_mat.gravity = Vector3(0, 0.9, 0) _smoke_mat.scale_min = 0.7 _smoke_mat.scale_max = 1.5 _smoke_mat.scale_over_velocity_min = 0.0 _smoke_mat.color_ramp = _ramp([Color(0.25, 0.23, 0.22, 0.0), Color(0.30, 0.29, 0.28, 0.45), Color(0.45, 0.45, 0.45, 0.0)]) _smoke_draw = _draw_quad(0.9, Color(0.3, 0.3, 0.3), false) _dust_mat = ParticleProcessMaterial.new() _dust_mat.direction = Vector3(0, 1, 0) _dust_mat.spread = 55.0 _dust_mat.initial_velocity_min = 1.0 _dust_mat.initial_velocity_max = 2.5 _dust_mat.gravity = Vector3(0, 0.4, 0) _dust_mat.scale_min = 0.6 _dust_mat.scale_max = 1.2 _dust_mat.color_ramp = _ramp([Color(0.75, 0.73, 0.70, 0.0), Color(0.72, 0.70, 0.66, 0.4), Color(0.7, 0.7, 0.7, 0.0)]) static var _fire_mat: ParticleProcessMaterial static var _fire_draw: QuadMesh static func explosion(parent: Node3D) -> void: ## Crashbreaker-grade fireball: additive fire core, rising smoke, spark ## shower. Layered one-shots, all self-freeing. _init_shared() if _fire_mat == null: _fire_mat = ParticleProcessMaterial.new() _fire_mat.direction = Vector3(0, 1, 0) _fire_mat.spread = 180.0 _fire_mat.initial_velocity_min = 6.0 _fire_mat.initial_velocity_max = 17.0 _fire_mat.gravity = Vector3(0, 3.0, 0) _fire_mat.scale_min = 0.8 _fire_mat.scale_max = 2.2 _fire_mat.damping_min = 4.0 _fire_mat.damping_max = 7.0 _fire_mat.color_ramp = _ramp([Color(1.0, 0.95, 0.6), Color(1.0, 0.55, 0.08), Color(0.85, 0.2, 0.02, 0.6), Color(0.2, 0.05, 0.02, 0.0)]) _fire_draw = _draw_quad(2.6, Color(1, 0.6, 0.15), true) var fire := GPUParticles3D.new() fire.process_material = _fire_mat fire.draw_pass_1 = _fire_draw fire.amount = 70 fire.lifetime = 0.85 fire.one_shot = true fire.explosiveness = 0.95 parent.add_child(fire) fire.emitting = true var smoke := GPUParticles3D.new() smoke.process_material = _smoke_mat smoke.draw_pass_1 = _smoke_draw smoke.amount = 30 smoke.lifetime = 2.0 smoke.one_shot = true smoke.explosiveness = 0.6 smoke.position = Vector3(0, 1.0, 0) parent.add_child(smoke) smoke.emitting = true sparks(parent, 60) parent.get_tree().create_timer(3.0).timeout.connect(func() -> void: fire.queue_free() smoke.queue_free()) static func sparks(parent: Node3D, amount := 24) -> void: ## One-shot burst at the parent's position, self-freeing. _init_shared() var p := GPUParticles3D.new() p.process_material = _spark_mat p.draw_pass_1 = _spark_draw p.amount = amount p.lifetime = 0.5 p.one_shot = true p.explosiveness = 1.0 parent.add_child(p) p.emitting = true parent.get_tree().create_timer(1.2).timeout.connect(p.queue_free) static func smoker(parent: Node3D, offset: Vector3 = Vector3(0, 0.5, 0)) -> GPUParticles3D: ## Persistent smoke column; caller toggles .emitting. _init_shared() var p := GPUParticles3D.new() p.process_material = _smoke_mat p.draw_pass_1 = _smoke_draw p.amount = 26 p.lifetime = 1.6 p.emitting = false p.position = offset parent.add_child(p) return p static func duster(parent: Node3D, offset: Vector3) -> GPUParticles3D: ## Tyre smoke/dust for drifts; caller toggles .emitting. _init_shared() var p := GPUParticles3D.new() p.process_material = _dust_mat p.draw_pass_1 = _smoke_draw p.amount = 20 p.lifetime = 0.9 p.emitting = false p.position = offset parent.add_child(p) return p static var _burnout_mat: ParticleProcessMaterial static var _steam_mat: ParticleProcessMaterial static var _rush_mat: ParticleProcessMaterial static var _rush_draw: QuadMesh static func rusher(cam: Node3D) -> GPUParticles3D: ## Motes streaming past the lens -- the oldest speed trick there is (Sonic ## did it in 2D, every racer since does it in 3D). Parented to the CAMERA in ## local space, so they fly straight past the viewer wherever it points, and ## SpeedFx's radial blur smears each speck into a streak for free. Caller ## drives .speed_scale and .emitting. _init_shared() if _rush_mat == null: _rush_mat = ParticleProcessMaterial.new() _rush_mat.direction = Vector3(0, 0, 1) # camera-space: toward the viewer _rush_mat.spread = 4.0 _rush_mat.initial_velocity_min = 55.0 _rush_mat.initial_velocity_max = 95.0 _rush_mat.gravity = Vector3.ZERO _rush_mat.scale_min = 0.5 _rush_mat.scale_max = 1.4 _rush_mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX _rush_mat.emission_box_extents = Vector3(15.0, 8.0, 22.0) _rush_mat.color_ramp = _ramp([Color(1, 1, 1, 0.0), Color(1, 1, 1, 0.55), Color(1, 1, 1, 0.0)]) _rush_draw = _draw_quad(0.075, Color(1, 1, 1), true) var p := GPUParticles3D.new() p.process_material = _rush_mat p.draw_pass_1 = _rush_draw p.amount = 90 p.lifetime = 0.75 p.local_coords = true p.position = Vector3(0, 0, -24.0) # spawn volume sits ahead of the lens p.emitting = false cam.add_child(p) return p static func burnout(parent: Node3D, offset: Vector3) -> GPUParticles3D: ## The good stuff: fat white tyre smoke for the burnout box. Caller toggles. _init_shared() if _burnout_mat == null: _burnout_mat = ParticleProcessMaterial.new() _burnout_mat.direction = Vector3(0, 1, 0) _burnout_mat.spread = 70.0 _burnout_mat.initial_velocity_min = 1.5 _burnout_mat.initial_velocity_max = 3.6 _burnout_mat.gravity = Vector3(0, 1.4, 0) _burnout_mat.scale_min = 1.2 _burnout_mat.scale_max = 2.6 _burnout_mat.color_ramp = _ramp([Color(0.92, 0.92, 0.92, 0.0), Color(0.88, 0.88, 0.88, 0.6), Color(0.80, 0.80, 0.80, 0.0)]) var p := GPUParticles3D.new() p.process_material = _burnout_mat p.draw_pass_1 = _smoke_draw p.amount = 60 p.lifetime = 1.7 p.emitting = false p.position = offset parent.add_child(p) return p static func steamer(parent: Node3D, offset: Vector3) -> GPUParticles3D: ## Head-gasket steam: a thin hot jet out of the bonnet. Caller toggles. _init_shared() if _steam_mat == null: _steam_mat = ParticleProcessMaterial.new() _steam_mat.direction = Vector3(0, 1, 0) _steam_mat.spread = 12.0 _steam_mat.initial_velocity_min = 3.5 _steam_mat.initial_velocity_max = 5.5 _steam_mat.gravity = Vector3(0, 2.5, 0) _steam_mat.scale_min = 0.5 _steam_mat.scale_max = 1.1 _steam_mat.color_ramp = _ramp([Color(0.95, 0.96, 0.98, 0.0), Color(0.93, 0.94, 0.96, 0.55), Color(0.9, 0.9, 0.92, 0.0)]) var p := GPUParticles3D.new() p.process_material = _steam_mat p.draw_pass_1 = _smoke_draw p.amount = 34 p.lifetime = 1.1 p.emitting = false p.position = offset parent.add_child(p) return p