Files
tekton/addons/godot_ai/handlers/particle_values.gd

229 lines
7.5 KiB
GDScript

@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)