editor-field-creation
Implement IFieldEditor interface for custom type rendering in script inspector. Covers reflection-based field editing, FieldEditorRegistry registration, boxing/unboxing patterns, and extending the field editor system for new types.
$ インストール
git clone https://github.com/kateusz/GameEngine /tmp/GameEngine && cp -r /tmp/GameEngine/.claude/skills/editor-field-creation ~/.claude/skills/GameEngine// tip: Run this command in your terminal to install the skill
name: editor-field-creation description: Implement IFieldEditor interface for custom type rendering in script inspector. Covers reflection-based field editing, FieldEditorRegistry registration, boxing/unboxing patterns, and extending the field editor system for new types.
Editor Field Editors (IFieldEditor)
Overview
Field Editors (IFieldEditor) provide runtime polymorphic rendering for script properties discovered via reflection. They use a non-generic, boxing-based interface to handle arbitrary types at runtime.
When to Use This Skill
- Creating custom field editors for new types (Quaternion, Color, custom structs)
- Understanding how script properties are rendered in Script Inspector
- Extending
FieldEditorRegistrywith new type support - Working with
UIPropertyRenderer.DrawPropertyField()infrastructure - NOT for component editors
Purpose & Context
Two Different Systems
The engine has two separate property editing systems:
| System | Purpose | Interface | Usage |
|---|---|---|---|
| IFieldEditor | Runtime script properties (reflection) | IFieldEditor (non-generic, boxing) | Script Inspector |
| VectorPanel/UIPropertyRenderer | Compile-time component properties | Static methods | Component Editors |
Core Interface
// Editor/UI/FieldEditors/IFieldEditor.cs
public interface IFieldEditor
{
/// <summary>
/// Draws the editor UI for the field and returns true if the value was changed.
/// </summary>
/// <param name="label">The ImGui label for the field (should include unique ID)</param>
/// <param name="value">The current field value (boxed)</param>
/// <param name="newValue">The new value if changed</param>
/// <returns>True if the value was modified by user interaction</returns>
bool Draw(string label, object value, out object newValue);
}
Key Characteristics:
- ❌ Not generic - no
IFieldEditor<T> - ✅ Boxing-based - uses
objectfor value and newValue - ✅ Returns bool - true if user changed the value
- ✅ Out parameter - newValue is the modified value
Built-in Field Editors
Primitive Type Editors
| Type | Implementation | File |
|---|---|---|
int | IntFieldEditor | IntFieldEditor.cs |
float | FloatFieldEditor | FloatFieldEditor.cs |
double | DoubleFieldEditor | DoubleFieldEditor.cs |
bool | BoolFieldEditor | BoolFieldEditor.cs |
string | StringFieldEditor | StringFieldEditor.cs |
Vector Type Editors
| Type | Implementation | File |
|---|---|---|
Vector2 | Vector2FieldEditor | Vector2FieldEditor.cs |
Vector3 | Vector3FieldEditor | Vector3FieldEditor.cs |
Vector4 | Vector4FieldEditor | Vector4FieldEditor.cs |
All registered in FieldEditorRegistry static dictionary.
FieldEditorRegistry
Central registry mapping types to editors:
// Editor/UI/FieldEditors/FieldEditorRegistry.cs
public static class FieldEditorRegistry
{
private static readonly Dictionary<Type, IFieldEditor> _editors = new()
{
{ typeof(int), new IntFieldEditor() },
{ typeof(float), new FloatFieldEditor() },
{ typeof(double), new DoubleFieldEditor() },
{ typeof(bool), new BoolFieldEditor() },
{ typeof(string), new StringFieldEditor() },
{ typeof(Vector2), new Vector2FieldEditor() },
{ typeof(Vector3), new Vector3FieldEditor() },
{ typeof(Vector4), new Vector4FieldEditor() }
};
public static IFieldEditor? GetEditor(Type type)
{
return _editors.TryGetValue(type, out var editor) ? editor : null;
}
public static bool HasEditor(Type type)
{
return _editors.ContainsKey(type);
}
}
Usage Pattern (ScriptComponentEditor.cs:111-113):
var editor = FieldEditorRegistry.GetEditor(fieldType);
if (editor != null)
return editor.Draw(label, value, out newValue);
Implementing a Custom Field Editor
Example 1: Simple Primitive Editor (IntFieldEditor)
// Editor/UI/FieldEditors/IntFieldEditor.cs
using ImGuiNET;
namespace Editor.UI.FieldEditors;
public class IntFieldEditor : IFieldEditor
{
public bool Draw(string label, object value, out object newValue)
{
var intValue = (int)value; // Unbox
var changed = ImGui.DragInt(label, ref intValue);
newValue = intValue; // Box
return changed;
}
}
Pattern:
- Unbox
object valueto concrete type - Call ImGui widget with
refparameter - Box result into
out object newValue - Return changed flag
Example 2: Vector Editor (Vector3FieldEditor)
// Editor/UI/FieldEditors/Vector3FieldEditor.cs
using System.Numerics;
using ImGuiNET;
namespace Editor.UI.FieldEditors;
public class Vector3FieldEditor : IFieldEditor
{
public bool Draw(string label, object value, out object newValue)
{
var v = (Vector3)value; // Unbox
var changed = ImGui.DragFloat3(label, ref v);
newValue = v; // Box
return changed;
}
}
Example 3: Custom Type Editor (Quaternion)
using System.Numerics;
using ImGuiNET;
namespace Editor.UI.FieldEditors;
public class QuaternionFieldEditor : IFieldEditor
{
public bool Draw(string label, object value, out object newValue)
{
var quat = (Quaternion)value;
// Convert to Euler angles for editing
var euler = QuaternionToEuler(quat);
var changed = ImGui.DragFloat3(label, ref euler);
if (changed)
{
// Convert back to quaternion
newValue = EulerToQuaternion(euler);
}
else
{
newValue = quat;
}
return changed;
}
private static Vector3 QuaternionToEuler(Quaternion q)
{
// Implementation...
}
private static Quaternion EulerToQuaternion(Vector3 euler)
{
// Implementation...
}
}
Registering Custom Field Editors
Step 1: Implement IFieldEditor
public class MyCustomTypeEditor : IFieldEditor
{
public bool Draw(string label, object value, out object newValue)
{
var typed = (MyCustomType)value;
// Draw UI and modify 'typed'
bool changed = /* ... */;
newValue = typed;
return changed;
}
}
Step 2: Register in FieldEditorRegistry
Option A: Add to static dictionary (modify FieldEditorRegistry.cs)
private static readonly Dictionary<Type, IFieldEditor> _editors = new()
{
// ... existing editors
{ typeof(MyCustomType), new MyCustomTypeEditor() }
};
Option B: Add runtime registration method (extensible)
// Add to FieldEditorRegistry.cs
public static void RegisterEditor(Type type, IFieldEditor editor)
{
_editors[type] = editor;
}
// Usage in initialization code
FieldEditorRegistry.RegisterEditor(typeof(Quaternion), new QuaternionFieldEditor());
Usage in Script Inspector
How ScriptComponentEditor Uses IFieldEditor
// ScriptComponentEditor.cs (simplified)
private bool TryDrawFieldEditor(string label, Type type, object value, out object newValue)
{
newValue = value;
var editor = FieldEditorRegistry.GetEditor(type);
if (editor != null)
return editor.Draw(label, value, out newValue);
// Fallback: unsupported type
ImGui.TextDisabled($"Unsupported type: {type.Name}");
return false;
}
// Called per script field
if (TryDrawFieldEditor(fieldLabel, fieldType, fieldValue, out var newValue))
{
script.SetFieldValue(fieldName, newValue); // Reflection-based assignment
}
Flow:
- Script reflection discovers field type at runtime
FieldEditorRegistry.GetEditor(type)looks up editor- If found, call
editor.Draw()with boxed value - If changed, use reflection to assign new value back to script field
Usage with UIPropertyRenderer
UIPropertyRenderer.DrawPropertyField()
Convenience wrapper for simple use cases:
// UIPropertyRenderer.cs:26-56
public static bool DrawPropertyField(string label, object? value, Action<object> onValueChanged)
{
if (value == null)
return false;
var valueType = value.GetType();
var editor = FieldEditorRegistry.GetEditor(valueType);
if (editor == null)
{
DrawPropertyRow(label, () =>
{
ImGui.TextDisabled($"Unsupported type: {valueType.Name}");
});
return false;
}
bool changed = false;
DrawPropertyRow(label, () =>
{
var inputLabel = $"##{label}";
if (editor.Draw(inputLabel, value, out var newValue))
{
onValueChanged(newValue);
changed = true;
}
});
return changed;
}
Usage (CameraComponentEditor.cs:22-23):
UIPropertyRenderer.DrawPropertyField("Primary", cameraComponent.Primary,
newValue => cameraComponent.Primary = (bool)newValue);
What it adds:
- Label/input column layout (33%/67% ratio)
- Null checking
- Fallback message for unsupported types
- Callback pattern instead of out parameter
Complete Example: Color Field Editor
using System.Numerics;
using ImGuiNET;
namespace Editor.UI.FieldEditors;
/// <summary>
/// Field editor for System.Drawing.Color or custom Color struct.
/// Renders as RGB sliders with preview.
/// </summary>
public class ColorFieldEditor : IFieldEditor
{
public bool Draw(string label, object value, out object newValue)
{
// Assume Color struct with R, G, B, A float properties (0-1 range)
var color = (Color)value;
// Convert to Vector4 for ImGui
var vec4 = new Vector4(color.R, color.G, color.B, color.A);
bool changed = ImGui.ColorEdit4(label, ref vec4,
ImGuiColorEditFlags.Float |
ImGuiColorEditFlags.AlphaPreview);
if (changed)
{
newValue = new Color(vec4.X, vec4.Y, vec4.Z, vec4.W);
}
else
{
newValue = color;
}
return changed;
}
}
// Register in FieldEditorRegistry
{ typeof(Color), new ColorFieldEditor() }
Performance Considerations
Boxing Overhead
IFieldEditor uses boxing for flexibility:
var intValue = (int)value; // Unboxing allocation
newValue = intValue; // Boxing allocation
Impact:
- 2 allocations per field per frame rendered
- Acceptable for script inspector (low frequency, few fields)
- NOT suitable for hot loops (use VectorPanel static methods instead)
When to Use Each System
| Scenario | Use This | Reason |
|---|---|---|
| Script public fields (reflection) | IFieldEditor | Type unknown at compile time |
| Component properties (known types) | VectorPanel / UIPropertyRenderer | No boxing, compile-time type safety |
| Hot loop / per-frame rendering | Static methods | Zero allocation |
| Custom type in scripts | IFieldEditor | Extensible via registry |
| Custom type in components | Custom static utility | Performance |
Anti-Patterns
❌ Anti-Pattern 1: Using IFieldEditor for Component Editors
// ❌ WRONG - Unnecessary boxing for known types
public class TransformComponentEditor : IComponentEditor
{
private readonly IFieldEditor _vector3Editor;
public void DrawComponent(Entity e)
{
var tc = e.GetComponent<TransformComponent>();
object pos = tc.Position; // Box
if (_vector3Editor.Draw("Position", pos, out var newPos))
{
tc.Position = (Vector3)newPos; // Unbox
}
}
}
// ✅ CORRECT - Use static VectorPanel methods
public class TransformComponentEditor : IComponentEditor
{
public void DrawComponent(Entity e)
{
var tc = e.GetComponent<TransformComponent>();
var newPos = tc.Position;
VectorPanel.DrawVec3Control("Position", ref newPos);
if (newPos != tc.Position)
tc.Position = newPos;
}
}
Why: Component types are known at compile time. Use static methods to avoid boxing.
❌ Anti-Pattern 2: Forgetting to Register Editor
// ❌ WRONG - Editor implemented but not registered
public class QuaternionFieldEditor : IFieldEditor { ... }
// Script inspector will show "Unsupported type: Quaternion"
// ✅ CORRECT - Register in FieldEditorRegistry
{ typeof(Quaternion), new QuaternionFieldEditor() }
❌ Anti-Pattern 3: Mutating Boxed Value Reference
// ❌ WRONG - Mutating unboxed value reference
public bool Draw(string label, object value, out object newValue)
{
var vec = (Vector3)value;
ImGui.DragFloat("X", ref vec.X); // Modifies local copy
newValue = value; // Returns original!
return true;
}
// ✅ CORRECT - Box the modified value
public bool Draw(string label, object value, out object newValue)
{
var vec = (Vector3)value;
bool changed = ImGui.DragFloat3(label, ref vec);
newValue = vec; // Box the modified value
return changed;
}
❌ Anti-Pattern 4: Not Handling Null Values
// ❌ WRONG - Crashes on null reference types
public bool Draw(string label, object value, out object newValue)
{
var str = (string)value; // NullReferenceException if value is null
// ...
}
// ✅ CORRECT - Handle null for reference types
public bool Draw(string label, object value, out object newValue)
{
var str = (value as string) ?? string.Empty;
// ...
newValue = str;
return changed;
}
Workflow: Adding a Custom Field Editor
Step 1: Create Field Editor Class
// Editor/UI/FieldEditors/MyTypeFieldEditor.cs
using ImGuiNET;
namespace Editor.UI.FieldEditors;
public class MyTypeFieldEditor : IFieldEditor
{
public bool Draw(string label, object value, out object newValue)
{
var typed = (MyType)value;
// TODO: Implement ImGui rendering
bool changed = false;
newValue = typed;
return changed;
}
}
Step 2: Implement Rendering Logic
public bool Draw(string label, object value, out object newValue)
{
var myValue = (MyType)value;
// Example: Edit two float fields
bool changed = false;
float field1 = myValue.Field1;
changed |= ImGui.DragFloat($"{label} Field1", ref field1);
float field2 = myValue.Field2;
changed |= ImGui.DragFloat($"{label} Field2", ref field2);
if (changed)
{
newValue = new MyType { Field1 = field1, Field2 = field2 };
}
else
{
newValue = myValue;
}
return changed;
}
Step 3: Register in FieldEditorRegistry
// FieldEditorRegistry.cs
private static readonly Dictionary<Type, IFieldEditor> _editors = new()
{
// ... existing editors
{ typeof(MyType), new MyTypeFieldEditor() }
};
Step 4: Test in Script Inspector
Create a test script with a public field:
public class TestScript : NativeScript
{
public MyType TestField = new();
// Field editor will automatically render this field in Script Inspector
}
Summary
IFieldEditor System
- ✅ Non-generic interface with boxing (
object value,out object newValue) - ✅ Runtime polymorphism for reflection-based script field editing
- ✅ FieldEditorRegistry maps
Type → IFieldEditor - ✅ Used by
ScriptComponentEditorandUIPropertyRenderer - ❌ Not for component editors (use VectorPanel/static methods instead)
When to Create Custom Field Editors
- Adding support for new types in script inspector
- Custom struct/class types used in scripts
- Specialized rendering for complex types (Quaternion, Color, etc.)
Key Differences: IFieldEditor vs. Component Editing
| Aspect | IFieldEditor | VectorPanel/UIPropertyRenderer |
|---|---|---|
| Interface | IFieldEditor.Draw() | Static methods |
| Type Safety | Boxing (object) | Generic/ref parameters |
| Performance | 2 allocs per field | Zero allocs |
| Purpose | Runtime script fields | Compile-time component properties |
| Registry | FieldEditorRegistry | N/A (static dispatch) |
| Extensibility | Type → Editor mapping | Add static methods |
Key Files
Editor/UI/FieldEditors/IFieldEditor.cs- Interface definitionEditor/UI/FieldEditors/FieldEditorRegistry.cs- Registry and lookupEditor/UI/FieldEditors/Vector3FieldEditor.cs- Example implementationEditor/ComponentEditors/ScriptComponentEditor.cs:111- Usage in script inspectorEditor/UI/Elements/UIPropertyRenderer.cs:32- Convenience wrapper
Repository
