@tool extends RefCounted ## Value coercion + gradient/curve builders for particle properties. const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd") const _EMISSION_SHAPES := { "point": ParticleProcessMaterial.EMISSION_SHAPE_POINT, "sphere": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, "sphere_surface": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE, "box": ParticleProcessMaterial.EMISSION_SHAPE_BOX, "points": ParticleProcessMaterial.EMISSION_SHAPE_POINTS, "directed_points": ParticleProcessMaterial.EMISSION_SHAPE_DIRECTED_POINTS, "ring": ParticleProcessMaterial.EMISSION_SHAPE_RING, } ## Resolve a shape name to the int enum, or return null. static func resolve_emission_shape(value: Variant) -> Variant: if value is int: return value if value is float: return int(value) if value is String: var key := String(value).to_lower() if _EMISSION_SHAPES.has(key): return _EMISSION_SHAPES[key] return null static func emission_shape_names() -> Array: return _EMISSION_SHAPES.keys() ## Build a Gradient from {stops: [{time, color}]} dict. static func build_gradient(value: Variant) -> Variant: if value is Gradient: return value if value is GradientTexture1D: return (value as GradientTexture1D).gradient if not (value is Dictionary): return null var d: Dictionary = value if not d.has("stops"): return null var stops_array = d.get("stops") if not (stops_array is Array): return null var offsets := PackedFloat32Array() var colors := PackedColorArray() for stop in stops_array: if not (stop is Dictionary): return null offsets.append(float(stop.get("time", 0.0))) var c = MaterialValues.parse_color(stop.get("color")) if c == null: return null colors.append(c) var grad := Gradient.new() grad.offsets = offsets grad.colors = colors return grad ## Build a GradientTexture1D wrapping a Gradient (what ParticleProcessMaterial.color_ramp wants). static func build_gradient_texture(value: Variant) -> Variant: if value is GradientTexture1D: return value var grad = build_gradient(value) if grad == null: return null var tex := GradientTexture1D.new() tex.gradient = grad return tex ## Build a Curve from [{time, value}] or {points: [...]} (float-over-time). static func build_curve(value: Variant) -> Variant: if value is Curve: return value if value is CurveTexture: return (value as CurveTexture).curve var points_array: Variant = null if value is Array: points_array = value elif value is Dictionary and value.has("points"): points_array = value["points"] if not (points_array is Array): return null var curve := Curve.new() for pt in points_array: if not (pt is Dictionary): return null var t := float(pt.get("time", 0.0)) var v := float(pt.get("value", 0.0)) curve.add_point(Vector2(t, v)) return curve static func build_curve_texture(value: Variant) -> Variant: if value is CurveTexture: return value var curve = build_curve(value) if curve == null: return null var tex := CurveTexture.new() tex.curve = curve return tex ## Coerce a particle property value to the appropriate type. ## Handles: Vector3/gravity/direction, Color, float, int, bool, enum strings. ## For color_ramp returns a GradientTexture1D; for *_curve returns CurveTexture. static func coerce(property: String, value: Variant, target_type: int) -> Dictionary: # Special-cased properties. if property == "emission_shape": var shape = resolve_emission_shape(value) if shape == null: return { "ok": false, "error": "Invalid emission_shape '%s'. Valid: %s" % [ value, ", ".join(emission_shape_names()) ], } return {"ok": true, "value": int(shape)} if property == "color_ramp" or property == "color_initial_ramp": var tex = build_gradient_texture(value) if tex == null: return {"ok": false, "error": "Invalid gradient for %s (expected {stops: [{time, color}]})" % property} return {"ok": true, "value": tex} if property == "color" and value is Dictionary and not (value as Dictionary).has("stops"): # color is a single Color, not a ramp. var c = MaterialValues.parse_color(value) if c == null: return {"ok": false, "error": "Invalid color"} return {"ok": true, "value": c} if property.ends_with("_curve"): var tex = build_curve_texture(value) if tex == null: return {"ok": false, "error": "Invalid curve for %s (expected [{time, value}])" % property} return {"ok": true, "value": tex} # Fall through to the material coercer (handles Color/Vec3/Vec2/float/int/bool/enum). return MaterialValues.coerce_material_value(property, value, target_type) ## Build a StandardMaterial3D suitable for GPUParticles3D draw-pass rendering. ## ## Godot's default Mesh has no material, which means ParticleProcessMaterial's ## color_ramp (which drives the COLOR varying) gets ignored and particles ## render as flat white squares that don't face the camera. A correct default ## must have vertex_color_use_as_albedo=true, billboard=particles, unshaded, ## and alpha transparency so the gradient actually modulates the pixels. ## ## Config is an optional dict that overrides individual properties. Supported ## keys match BaseMaterial3D properties (plus enum-by-name via MaterialValues): ## blend_mode: "mix" | "add" | "sub" | "mul" ## transparency: "disabled" | "alpha" | "alpha_scissor" | "alpha_hash" | "alpha_depth_pre_pass" ## shading_mode: "unshaded" | "per_pixel" | "per_vertex" ## billboard_mode: "disabled" | "enabled" | "fixed_y" | "particles" ## vertex_color_use_as_albedo: bool ## emission_enabled: bool ## emission: Color ## emission_energy_multiplier: float ## albedo_color: Color ## albedo_texture: res:// path ## (anything else accepted by BaseMaterial3D.set()) static func build_draw_material(config: Dictionary) -> StandardMaterial3D: var mat := StandardMaterial3D.new() # Sensible defaults for particle draw-pass rendering. mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.vertex_color_use_as_albedo = true mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES mat.billboard_keep_scale = true # Configure from dict overrides. for key in config: var prop_name := String(key) var prop_type := _object_property_type(mat, prop_name) if prop_type == TYPE_NIL: continue var coerce_result := MaterialValues.coerce_material_value( prop_name, config[prop_name], prop_type ) if coerce_result.ok: mat.set(prop_name, coerce_result.value) return mat static func _object_property_type(obj: Object, name: String) -> int: if obj == null: return TYPE_NIL for prop in obj.get_property_list(): if prop.name == name: return int(prop.get("type", TYPE_NIL)) return TYPE_NIL ## Serialize for response. static func serialize(value: Variant) -> Variant: if value == null: return null if value is GradientTexture1D: var grad := (value as GradientTexture1D).gradient if grad == null: return {"type": "GradientTexture1D", "stops": []} var stops: Array = [] for i in grad.offsets.size(): var c: Color = grad.colors[i] stops.append({ "time": grad.offsets[i], "color": {"r": c.r, "g": c.g, "b": c.b, "a": c.a}, }) return {"type": "GradientTexture1D", "stops": stops} if value is CurveTexture: var curve := (value as CurveTexture).curve if curve == null: return {"type": "CurveTexture", "points": []} var points: Array = [] for i in curve.get_point_count(): var p := curve.get_point_position(i) points.append({"time": p.x, "value": p.y}) return {"type": "CurveTexture", "points": points} return MaterialValues.serialize_value(value)